4 files changed
+173
-3
No files matched your search
@@ -611,3 +611,94 @@ describe("RedisKeyBrowser group key counts reflect incomplete loading (issue #63
|
||||
expect(countBadge?.getAttribute("title")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser group subtree fill on expand (issue #7162)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.redisScanPageSize = 100;
|
||||
});
|
||||
|
||||
function scanCallsWithPattern(pattern: string): number {
|
||||
return mocks.redisScanKeysBatch.mock.calls.filter((call: unknown[]) => call[3] === pattern).length;
|
||||
}
|
||||
|
||||
// Clicks the group row itself. `[data-redis-group]` marks the group's
|
||||
// selection checkbox: it carries `cursor-pointer` itself and consumes the
|
||||
// click, so a `closest()` from it can never reach the expanding row — walk
|
||||
// up to the checkbox's row container (the div with the row click handler)
|
||||
// instead.
|
||||
function expandFirstGroupRow(host: HTMLElement) {
|
||||
const checkbox = host.querySelector<HTMLElement>("[data-redis-group]");
|
||||
const row = checkbox?.parentElement?.parentElement;
|
||||
expect(row?.className).toContain("cursor-pointer");
|
||||
row!.click();
|
||||
}
|
||||
|
||||
// Overflowing viewport: rows render (RecycleScroller buffer 600) while the
|
||||
// bounded auto-fill stays off, reproducing the stranded state from the
|
||||
// issue — the main scan is still open (hasMore), so the tree is silently
|
||||
// partial and a key that search finds may not exist in the tree at all.
|
||||
function stubOverflowingViewport() {
|
||||
stubNonOverflowingViewport();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("redis-key-scroller") ? 5000 : 100;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("fills the expanded group's subtree via a dedicated prefix scan without advancing the main scan", async () => {
|
||||
stubOverflowingViewport();
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||
if (pattern === "grp:*") {
|
||||
return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:hidden"), keyInfo("grp:sub:x")], total_keys: 0 });
|
||||
}
|
||||
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 });
|
||||
return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 });
|
||||
});
|
||||
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
// Only the first browse page ran: the tree holds `grp:a` alone and the
|
||||
// main scan is still open.
|
||||
expect(scanCallsWithPattern("*")).toBe(1);
|
||||
expect(scanCallsWithPattern("grp:*")).toBe(0);
|
||||
expect(host.textContent).toContain("grp");
|
||||
|
||||
expandFirstGroupRow(host);
|
||||
await settleThoroughly();
|
||||
|
||||
// Expanding the group scanned its subtree with a fresh cursor; the main
|
||||
// browse cursor was not advanced (no extra `*` pages).
|
||||
expect(scanCallsWithPattern("grp:*")).toBe(1);
|
||||
expect(scanCallsWithPattern("*")).toBe(1);
|
||||
expect(host.textContent).toContain("hidden");
|
||||
expect(host.textContent).toContain("sub");
|
||||
|
||||
// Re-collapse + re-expand must not rescan the already-completed subtree.
|
||||
expandFirstGroupRow(host);
|
||||
await settleThoroughly();
|
||||
expandFirstGroupRow(host);
|
||||
await settleThoroughly();
|
||||
expect(scanCallsWithPattern("grp:*")).toBe(1);
|
||||
});
|
||||
|
||||
it("skips the subtree scan when the main scan already completed", async () => {
|
||||
stubOverflowingViewport();
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||
expect(pattern).toBe("*");
|
||||
if (cursor === 0) return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:a"), keyInfo("grp:b")], total_keys: 2 });
|
||||
return Promise.resolve({ cursor: 0, keys: [], total_keys: 0 });
|
||||
});
|
||||
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
expandFirstGroupRow(host);
|
||||
await settleThoroughly();
|
||||
|
||||
expect(scanCallsWithPattern("grp:*")).toBe(0);
|
||||
expect(host.textContent).toContain("b");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
|
||||
import { redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
|
||||
import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
|
||||
@@ -198,6 +198,8 @@ let redisDbFlushedListenerRegistered = false;
|
||||
let redisInfiniteScrollFrame = 0;
|
||||
const loadedKeyRaws = new Set<string>();
|
||||
let treeIndex: RedisKeyTreeIndex | null = null;
|
||||
// 展开分组时已定向补扫过的子树(见 fillGroupSubtree);在 loadKeys 重置。
|
||||
const subtreeFilledGroupIds = new Set<string>();
|
||||
const REDIS_COMMAND_COMPLETION_MENU_LIMIT = 12;
|
||||
let commandCompletionRequestId = 0;
|
||||
let commandDocumentationConnectionId: string | null = null;
|
||||
@@ -745,6 +747,7 @@ async function loadKeys() {
|
||||
loading.value = true;
|
||||
loadedKeyRaws.clear();
|
||||
ttlObservedAtByRaw.clear();
|
||||
subtreeFilledGroupIds.clear();
|
||||
flatKeys.value = [];
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
@@ -913,12 +916,58 @@ function stopFetchAll() {
|
||||
fetchAllStopRequested.value = true;
|
||||
}
|
||||
|
||||
// 展开分组时的定向补扫:树模式的自动加载只覆盖有界 SCAN 预算内的键,
|
||||
// 未扫到的分组在树里完全不存在(搜索能找到、树里看不到)。展开分组时用
|
||||
// `前缀:*` 模式以独立游标扫描该子树直至耗尽,让"展开即可见"成立;补扫
|
||||
// 结果经 loadedKeyRaws 去重后并入主树,主 SCAN 游标不受影响。
|
||||
const SUBTREE_SCAN_ITERATIONS_PER_CALL = 8;
|
||||
|
||||
function shouldFillGroupSubtree(): boolean {
|
||||
return hasMore.value && !useFlatKeySearchRows.value && !isSearchMode.value && !isFetchingAll.value;
|
||||
}
|
||||
|
||||
function mergeScannedKeys(newKeys: RedisKeyInfo[]) {
|
||||
if (newKeys.length === 0) return;
|
||||
for (const key of newKeys) recordKeyTtlObservedAt(key);
|
||||
flatKeys.value = [...flatKeys.value, ...newKeys];
|
||||
mergeTree(newKeys);
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) {
|
||||
if (subtreeFilledGroupIds.has(group.id)) return;
|
||||
subtreeFilledGroupIds.add(group.id);
|
||||
const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value);
|
||||
let cursor = 0;
|
||||
try {
|
||||
while (requestId === searchRequestId && !isFetchingAll.value) {
|
||||
if (flatKeys.value.length >= redisInfiniteScrollMaxKeys.value) break;
|
||||
const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, SUBTREE_SCAN_ITERATIONS_PER_CALL, true);
|
||||
if (requestId !== searchRequestId) return;
|
||||
mergeScannedKeys(collectUniqueRedisKeys(result.keys, loadedKeyRaws));
|
||||
cursor = result.cursor;
|
||||
if (cursor === 0) break;
|
||||
}
|
||||
} catch (error) {
|
||||
// 失败不阻塞浏览;下次展开该分组会重试
|
||||
subtreeFilledGroupIds.delete(group.id);
|
||||
toast(errorMessage(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
const next = new Set(expandedGroupIds.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
const expanding = !next.has(groupId);
|
||||
if (expanding) next.add(groupId);
|
||||
else next.delete(groupId);
|
||||
expandedGroupIds.value = next;
|
||||
void maybeAutoLoadMoreRedisKeys();
|
||||
if (expanding && shouldFillGroupSubtree()) {
|
||||
const group = treeIndex?.groupById.get(groupId);
|
||||
if (group) void fillGroupSubtree(group);
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
|
||||
|
||||
describe("redisGroupSubtreePattern", () => {
|
||||
it("builds a prefix pattern for single and nested groups", () => {
|
||||
expect(redisGroupSubtreePattern(["grp"])).toBe("grp:*");
|
||||
expect(redisGroupSubtreePattern(["grp", "sub"])).toBe("grp:sub:*");
|
||||
});
|
||||
|
||||
it("supports custom separators and preserves empty segments", () => {
|
||||
expect(redisGroupSubtreePattern(["a", "b"], "/")).toBe("a/b/*");
|
||||
expect(redisGroupSubtreePattern(["a", "", "c"])).toBe("a::c:*");
|
||||
});
|
||||
|
||||
it("escapes glob metacharacters in every segment", () => {
|
||||
expect(redisGroupSubtreePattern(["a*b"])).toBe("a\\*b:*");
|
||||
expect(redisGroupSubtreePattern(["order?]", "x[y"])).toBe("order\\?\\]:x\\[y:*");
|
||||
expect(redisGroupSubtreePattern(["back\\slash"])).toBe("back\\\\slash:*");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,16 @@ export function redisKeySearchPattern(value: string, fuzzy: boolean): string {
|
||||
return fuzzy ? `*${escapeRedisGlobText(pattern, fuzzy)}*` : pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* SCAN MATCH pattern covering exactly the subtree of a tree group: every
|
||||
* segment is glob-escaped so keys containing `*`/`?`/`[`/`]` match literally,
|
||||
* and the trailing `*` only widens to the group's descendants.
|
||||
*/
|
||||
export function redisGroupSubtreePattern(pathSegments: readonly string[], separator = ":"): string {
|
||||
const prefix = pathSegments.map((segment) => escapeRedisGlobText(segment)).join(separator);
|
||||
return `${prefix}${separator}*`;
|
||||
}
|
||||
|
||||
// Redis scan page size (COUNT parameter per SCAN round-trip) — shared defaults
|
||||
// and validation bounds used by the connection form and key browser.
|
||||
export const REDIS_SCAN_PAGE_SIZE_DEFAULT = 1000;
|
||||
|
||||
Reference in new issue
Block a user