From 1cbb5abf3bd187507db3f27925d8ed75895414ff Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Wed, 9 Sep 2026 14:44:45 +0800 Subject: [PATCH] feat(redis): add custom key groups --- .../connection/ConnectionDialog.vue | 2 + .../src/components/redis/RedisGroupHeader.vue | 40 +++ .../RedisGroupedKeyList.publication.spec.ts | 239 ++++++++++++++++ .../redis/RedisGroupedKeyList.spec.ts | 98 +++++++ .../components/redis/RedisGroupedKeyList.vue | 246 +++++++++++++++++ .../RedisKeyBrowser.groupingToggle.spec.ts | 11 + .../RedisKeyBrowser.infiniteScroll.spec.ts | 151 ++++++++++- .../src/components/redis/RedisKeyBrowser.vue | 209 +++++++++++++- .../redis/RedisKeyGroupingDialog.vue | 79 ++++++ apps/desktop/src/i18n/locales/en.ts | 2 + .../desktop/src/i18n/locales/redisGrouping.ts | 84 ++++++ apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/redisKeyGroupBoundary.spec.ts | 43 +++ .../redis/__tests__/redisKeyGrouping.spec.ts | 94 +++++++ .../__tests__/redisKeyViewScheduler.spec.ts | 42 +++ .../__tests__/redisStickyHeaders.spec.ts | 74 +++++ .../src/lib/redis/redisKeyGroupIndex.ts | 255 ++++++++++++++++++ .../desktop/src/lib/redis/redisKeyGrouping.ts | 109 ++++++++ .../src/lib/redis/redisKeyViewScheduler.ts | 12 + .../src/lib/redis/redisStickyHeaders.ts | 27 ++ .../connectionStore.redisGrouping.spec.ts | 25 ++ apps/desktop/src/stores/connectionStore.ts | 13 + apps/desktop/src/types/database.ts | 1 + .../examples/table_import_live_bench.rs | 1 + crates/dbx-core/src/agent_connection.rs | 1 + crates/dbx-core/src/agent_tools.rs | 1 + crates/dbx-core/src/cloud_sync.rs | 3 + crates/dbx-core/src/connection.rs | 1 + crates/dbx-core/src/connection_secrets.rs | 1 + crates/dbx-core/src/db/redis_driver.rs | 1 + crates/dbx-core/src/models/connection.rs | 70 +++++ crates/dbx-core/src/mq/config.rs | 1 + crates/dbx-core/src/mq/service.rs | 1 + crates/dbx-core/src/nacos/config.rs | 1 + crates/dbx-core/src/nacos/service.rs | 2 + crates/dbx-core/src/production_safety.rs | 1 + crates/dbx-core/src/query.rs | 2 + crates/dbx-core/src/schema.rs | 1 + crates/dbx-core/src/storage.rs | 2 + crates/dbx-core/src/transfer.rs | 2 + crates/dbx-core/tests/dump_docs_fixture.rs | 1 + .../tests/live_postgres_docs_annotations.rs | 1 + .../tests/live_postgres_docs_snapshot.rs | 1 + .../live_postgres_query_result_export.rs | 1 + .../dbx-core/tests/live_postgres_transfer.rs | 1 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + .../dbx-core/tests/live_sqlserver_transfer.rs | 1 + crates/dbx-core/tests/support/mod.rs | 1 + crates/dbx-web/src/routes/connection.rs | 1 + crates/dbx-web/src/routes/transfer.rs | 1 + src-tauri/src/commands/connection.rs | 1 + 53 files changed, 1951 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisGroupHeader.vue create mode 100644 apps/desktop/src/components/redis/RedisGroupedKeyList.publication.spec.ts create mode 100644 apps/desktop/src/components/redis/RedisGroupedKeyList.spec.ts create mode 100644 apps/desktop/src/components/redis/RedisGroupedKeyList.vue create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.groupingToggle.spec.ts create mode 100644 apps/desktop/src/components/redis/RedisKeyGroupingDialog.vue create mode 100644 apps/desktop/src/i18n/locales/redisGrouping.ts create mode 100644 apps/desktop/src/lib/redis/__tests__/redisKeyGroupBoundary.spec.ts create mode 100644 apps/desktop/src/lib/redis/__tests__/redisKeyGrouping.spec.ts create mode 100644 apps/desktop/src/lib/redis/__tests__/redisKeyViewScheduler.spec.ts create mode 100644 apps/desktop/src/lib/redis/__tests__/redisStickyHeaders.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisKeyGroupIndex.ts create mode 100644 apps/desktop/src/lib/redis/redisKeyGrouping.ts create mode 100644 apps/desktop/src/lib/redis/redisKeyViewScheduler.ts create mode 100644 apps/desktop/src/lib/redis/redisStickyHeaders.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.redisGrouping.spec.ts 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 @@ + + + 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 @@ + + + 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 });