diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 07ee65f710..dd7fc9e95c 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -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 || ""); diff --git a/apps/desktop/src/components/redis/RedisGroupHeader.vue b/apps/desktop/src/components/redis/RedisGroupHeader.vue new file mode 100644 index 0000000000..248b8aafb1 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisGroupHeader.vue @@ -0,0 +1,40 @@ + + + + + + + + + … + {{ row.label }} + {{ t("redisGrouping.count", { count: row.count }) }} + + {{ path }} + + + + {{ row.label }} + {{ t("redisGrouping.count", { count: row.count }) }} + + diff --git a/apps/desktop/src/components/redis/RedisGroupedKeyList.publication.spec.ts b/apps/desktop/src/components/redis/RedisGroupedKeyList.publication.spec.ts new file mode 100644 index 0000000000..9404e02534 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisGroupedKeyList.publication.spec.ts @@ -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([]); + const scope = shallowRef("c:0"); + const host = document.createElement("div"); + document.body.appendChild(host); + const config = shallowRef(customConfig); + const checked = new Set(); + const selected = shallowRef(null); + const onSelect = vi.fn(); + const onCheck = vi.fn(); + const list = shallowRef | 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('[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('[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(".redis-key-scroller")!; + element.scrollTop = 1500; + element.dispatchEvent(new Event("scroll")); + await nextTick(); + const pinned = state.host.querySelector("[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("[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("[data-redis-group-header]")) + .find((node) => node.dataset.redisGroupHeader === 'custom:"first"')! + .click(); + await flushPublication(); + state.host.querySelector('[data-redis-group-header="custom:unmatched"]')!.click(); + await flushPublication(); + const element = state.host.querySelector(".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("[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("[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("[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(".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("[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("[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("[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(); +}); diff --git a/apps/desktop/src/components/redis/RedisGroupedKeyList.spec.ts b/apps/desktop/src/components/redis/RedisGroupedKeyList.spec.ts new file mode 100644 index 0000000000..f70c8149c5 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisGroupedKeyList.spec.ts @@ -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("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('[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(".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(); +}); diff --git a/apps/desktop/src/components/redis/RedisGroupedKeyList.vue b/apps/desktop/src/components/redis/RedisGroupedKeyList.vue new file mode 100644 index 0000000000..c102044232 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisGroupedKeyList.vue @@ -0,0 +1,246 @@ + + + + + + {{ t("redisGrouping.fuzzyTreeUnavailable") }} + {{ t("redisGrouping.useList") }} + + + {{ working ? t("redisGrouping.preparing") : t("redisGrouping.loadedOnly") }} + + + + + + + + {{ item.label || t("redisGrouping.emptyKey") }} + {{ item.key.key_type }} + + + + + + + + + + + + diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.groupingToggle.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.groupingToggle.spec.ts new file mode 100644 index 0000000000..6bb637aec0 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.groupingToggle.spec.ts @@ -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(/]*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 })"); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts index 9df3913725..2c35127fef 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -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(); +vi.mock("@/lib/redis/redisKeyViewScheduler", () => ({ createRedisKeyViewYield: () => () => new Promise((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("[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(); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 4ef5911ad1..1b4fda05a3 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -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 = 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(); const keyPaneRef = ref(); const redisKeyScrollerRef = ref | null>(null); +const groupedKeyListRef = ref | 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 { 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 { 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 }); + @@ -2754,7 +2894,17 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); {{ keyCountText }} - {{ t("redis.selectAllLoaded") }} + {{ customGrouping.enabled ? t("redisGrouping.selectLoaded") : t("redis.selectAllLoaded") }} {{ t("redis.deselectAll") }} {{ checkedKeys.size }} @@ -2833,7 +2983,48 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); - + + {{ t("redisGrouping.toggle") }} + {{ + t(customGrouping.inner_view === "list" ? "redisGrouping.list" : "redisGrouping.tree") + }} + {{ t("redisGrouping.settings") }} + {{ groupingError }} + + {{ t("redisGrouping.preparingLegacy") }} + 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)" + /> + {{ t("redis.noKeysInScanHint") }} @@ -2931,7 +3122,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); - + {{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }} diff --git a/apps/desktop/src/components/redis/RedisKeyGroupingDialog.vue b/apps/desktop/src/components/redis/RedisKeyGroupingDialog.vue new file mode 100644 index 0000000000..a0d8826cf1 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyGroupingDialog.vue @@ -0,0 +1,79 @@ + + + + + + {{ t("redisGrouping.settings") }} + {{ t("redisGrouping.help") }} + + + + + + ↑ + ↓ + {{ t("redisGrouping.remove") }} + + + {{ t("redisGrouping.includes") }} + {{ t("redisGrouping.excludes") }} + + + + + {{ t("redisGrouping.add") }} + {{ t("redisGrouping.example") }} + + {{ error || t("redisGrouping.invalid") }} + + {{ t("redisGrouping.cancel") }} + {{ t("redisGrouping.save") }} + + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a3933ab4dc..654ec509cf 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/redisGrouping.ts b/apps/desktop/src/i18n/locales/redisGrouping.ts new file mode 100644 index 0000000000..a91939baa8 --- /dev/null +++ b/apps/desktop/src/i18n/locales/redisGrouping.ts @@ -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: "選擇已載入匹配項", +}; diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6c7dc7149b..9570b0842b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "基础类型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a7cc691720..51614bf5af 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "基礎型別", diff --git a/apps/desktop/src/lib/redis/__tests__/redisKeyGroupBoundary.spec.ts b/apps/desktop/src/lib/redis/__tests__/redisKeyGroupBoundary.spec.ts new file mode 100644 index 0000000000..4506ce0218 --- /dev/null +++ b/apps/desktop/src/lib/redis/__tests__/redisKeyGroupBoundary.spec.ts @@ -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"); +}); diff --git a/apps/desktop/src/lib/redis/__tests__/redisKeyGrouping.spec.ts b/apps/desktop/src/lib/redis/__tests__/redisKeyGrouping.spec.ts new file mode 100644 index 0000000000..bf9136b395 --- /dev/null +++ b/apps/desktop/src/lib/redis/__tests__/redisKeyGrouping.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/redis/__tests__/redisKeyViewScheduler.spec.ts b/apps/desktop/src/lib/redis/__tests__/redisKeyViewScheduler.spec.ts new file mode 100644 index 0000000000..e76a5feb17 --- /dev/null +++ b/apps/desktop/src/lib/redis/__tests__/redisKeyViewScheduler.spec.ts @@ -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); +}); diff --git a/apps/desktop/src/lib/redis/__tests__/redisStickyHeaders.spec.ts b/apps/desktop/src/lib/redis/__tests__/redisStickyHeaders.spec.ts new file mode 100644 index 0000000000..2b5819ba9a --- /dev/null +++ b/apps/desktop/src/lib/redis/__tests__/redisStickyHeaders.spec.ts @@ -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"]); +}); diff --git a/apps/desktop/src/lib/redis/redisKeyGroupIndex.ts b/apps/desktop/src/lib/redis/redisKeyGroupIndex.ts new file mode 100644 index 0000000000..ba489e3a5a --- /dev/null +++ b/apps/desktop/src/lib/redis/redisKeyGroupIndex.ts @@ -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; + keys: RedisKeyInfo[]; +} +interface Group { + id: string; + label: string; + keys: RedisKeyInfo[]; + tree?: Namespace; + treeLength: number; +} +export interface GroupWork { + current: () => boolean; + yield: () => Promise; +} +const CHUNK = 512; + +/** Non-reactive index: ordinary append batches classify only new identities. */ +export class RedisKeyGroupIndex { + readonly groups: Group[]; + private readonly classify: ReturnType; + private readonly byId = new Map(); + private readonly locations = new Map(); + 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 { + 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, 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(); + const outerGroups: RedisOuterGroupBoundary[] = []; + const headerBoundaries: RedisHeaderBoundary[] = []; + const openHeaders: RedisHeaderBoundary[] = []; + const keyRanges = new Map(); + 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; 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; + } +} diff --git a/apps/desktop/src/lib/redis/redisKeyGrouping.ts b/apps/desktop/src/lib/redis/redisKeyGrouping.ts new file mode 100644 index 0000000000..940cfb90aa --- /dev/null +++ b/apps/desktop/src/lib/redis/redisKeyGrouping.ts @@ -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; + 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(); + 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; 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(); + 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; + }; +} diff --git a/apps/desktop/src/lib/redis/redisKeyViewScheduler.ts b/apps/desktop/src/lib/redis/redisKeyViewScheduler.ts new file mode 100644 index 0000000000..cc8a5f29e6 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisKeyViewScheduler.ts @@ -0,0 +1,12 @@ +/** Small cancellation checkpoints need not each consume a browser frame. */ +export function createRedisKeyViewYield(options: { now?: () => number; yieldTask?: () => Promise; budgetMs?: number } = {}): () => Promise { + const now = options.now ?? (() => performance.now()); + const yieldTask = options.yieldTask ?? (() => new Promise((resolve) => setTimeout(resolve, 0))); + const budget = options.budgetMs ?? 8; + let started = now(); + return async () => { + if (now() - started < budget) return; + await yieldTask(); + started = now(); + }; +} diff --git a/apps/desktop/src/lib/redis/redisStickyHeaders.ts b/apps/desktop/src/lib/redis/redisStickyHeaders.ts new file mode 100644 index 0000000000..f29b34801e --- /dev/null +++ b/apps/desktop/src/lib/redis/redisStickyHeaders.ts @@ -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) { + return sticky.reduce((height, row) => Math.max(height, row.top + REDIS_GROUP_ROW_HEIGHT), 0); +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.redisGrouping.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.redisGrouping.spec.ts new file mode 100644 index 0000000000..81f63cc8b0 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.redisGrouping.spec.ts @@ -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); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index bae98426f6..72d556545a 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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 { const trimmed = name.trim(); if (!trimmed) return false; @@ -8970,6 +8982,7 @@ export const useConnectionStore = defineStore("connection", () => { pasteConnectionClipboard, addEphemeralConnection, updateConnection, + updateRedisKeyGrouping, renameConnection, applyGlobalTimeouts, updateConnectionDatabaseInfo, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index cc102fd344..7a129f4b15 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -126,6 +126,7 @@ export interface ConnectionConfig { redis_database_aliases?: Record; /** 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; diff --git a/crates/dbx-core/examples/table_import_live_bench.rs b/crates/dbx-core/examples/table_import_live_bench.rs index 88a2db2204..815fd5a3ca 100644 --- a/crates/dbx-core/examples/table_import_live_bench.rs +++ b/crates/dbx-core/examples/table_import_live_bench.rs @@ -199,6 +199,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result Option(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, + pub excludes: Vec, +} + +#[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, +} + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub redis_key_grouping: Option, #[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, #[serde(default)] + pub redis_key_grouping: Option, + #[serde(default)] pub etcd_endpoints: String, #[serde(default)] pub gbase_server: String, @@ -669,6 +697,7 @@ impl From 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::(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(), diff --git a/crates/dbx-core/src/mq/config.rs b/crates/dbx-core/src/mq/config.rs index 9c20894c04..d44c5c7301 100644 --- a/crates/dbx-core/src/mq/config.rs +++ b/crates/dbx-core/src/mq/config.rs @@ -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(), diff --git a/crates/dbx-core/src/mq/service.rs b/crates/dbx-core/src/mq/service.rs index 3ffdbe97f9..7d77ea11b1 100644 --- a/crates/dbx-core/src/mq/service.rs +++ b/crates/dbx-core/src/mq/service.rs @@ -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(), diff --git a/crates/dbx-core/src/nacos/config.rs b/crates/dbx-core/src/nacos/config.rs index 923574c25b..8e94117f69 100644 --- a/crates/dbx-core/src/nacos/config.rs +++ b/crates/dbx-core/src/nacos/config.rs @@ -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(), diff --git a/crates/dbx-core/src/nacos/service.rs b/crates/dbx-core/src/nacos/service.rs index 12a75c205f..c0c69bfbeb 100644 --- a/crates/dbx-core/src/nacos/service.rs +++ b/crates/dbx-core/src/nacos/service.rs @@ -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(), diff --git a/crates/dbx-core/src/production_safety.rs b/crates/dbx-core/src/production_safety.rs index 591cfabf76..4a008c3a73 100644 --- a/crates/dbx-core/src/production_safety.rs +++ b/crates/dbx-core/src/production_safety.rs @@ -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(), diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index c288d57879..2b3981cb1d 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -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(), diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index b3571d768a..2def70adef 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -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(), diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 70e35cbf00..e07c4f3b60 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -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(), diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 302813c89c..14b7ea449c 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -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(), diff --git a/crates/dbx-core/tests/dump_docs_fixture.rs b/crates/dbx-core/tests/dump_docs_fixture.rs index bb34b52de5..563271fea2 100644 --- a/crates/dbx-core/tests/dump_docs_fixture.rs +++ b/crates/dbx-core/tests/dump_docs_fixture.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_postgres_docs_annotations.rs b/crates/dbx-core/tests/live_postgres_docs_annotations.rs index f7d1b436d2..50b24c2efc 100644 --- a/crates/dbx-core/tests/live_postgres_docs_annotations.rs +++ b/crates/dbx-core/tests/live_postgres_docs_annotations.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_postgres_docs_snapshot.rs b/crates/dbx-core/tests/live_postgres_docs_snapshot.rs index b78d611ca4..a217073cda 100644 --- a/crates/dbx-core/tests/live_postgres_docs_snapshot.rs +++ b/crates/dbx-core/tests/live_postgres_docs_snapshot.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index 05fe1f1c94..b2ed9a5afb 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index f5a98e8acb..203494e06b 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 36629e1a55..5d3bc9b96a 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs index 1e2bf5035b..a24dd1b77e 100644 --- a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs +++ b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs @@ -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(), diff --git a/crates/dbx-core/tests/live_sqlserver_transfer.rs b/crates/dbx-core/tests/live_sqlserver_transfer.rs index e3c64585bf..4d66d83795 100644 --- a/crates/dbx-core/tests/live_sqlserver_transfer.rs +++ b/crates/dbx-core/tests/live_sqlserver_transfer.rs @@ -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(), diff --git a/crates/dbx-core/tests/support/mod.rs b/crates/dbx-core/tests/support/mod.rs index c00ed8219e..36b90858ee 100644 --- a/crates/dbx-core/tests/support/mod.rs +++ b/crates/dbx-core/tests/support/mod.rs @@ -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(), diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 553e59e002..f14be71e14 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -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(), diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index ce2ea4fe4b..4ae1e3283b 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -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(), diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 87e0c46942..36e7dadc34 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -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(),
{{ t("redisGrouping.fuzzyTreeUnavailable") }}
{{ working ? t("redisGrouping.preparing") : t("redisGrouping.loadedOnly") }}
{{ t("redisGrouping.preparingLegacy") }}
{{ t("redisGrouping.help") }}
{{ error || t("redisGrouping.invalid") }}