fix(redis): preserve viewport after fetch all

This commit is contained in:
Guoyu Su
2026-09-05 01:03:30 +08:00
committed by GitHub
parent 14168cc36a
commit 61aa00bf8f
2 changed files with 352 additions and 41 deletions
@@ -29,7 +29,12 @@ const mocks = vi.hoisted(() => ({
createRedisKeyTreeIndex: vi.fn(),
flattenVisibleRedisKeyTree: vi.fn(),
scrollerUpdateVisibleItems: vi.fn(),
scrollerScrollToItem: vi.fn(),
scrollerCallOrder: [] as string[],
scrollerVisiblePositions: [] as number[],
scrollerRefreshSnapshots: [] as Array<{ length: number; ids: Array<string | undefined>; hasHoles: boolean }>,
scrollerItems: [] as unknown[][],
scrollerVisibleStartIndex: 0,
toast: vi.fn(),
updateRedisDbKeyStats: vi.fn(),
listRedisCompletionCommandDocs: vi.fn(),
@@ -344,17 +349,38 @@ vi.mock("vue-virtual-scroller", async () => {
// Mirror the real scroller: interaction tests should render a viewport,
// not every row in a deliberately large result set.
const visibleItemCount = 50;
let layoutItemCount = props.items.length;
const renderedItems = shallowRef<unknown[]>([]);
const bindVisibleItems = () => {
renderedItems.value = props.items.slice(0, visibleItemCount);
renderedItems.value = props.items.slice(mocks.scrollerVisibleStartIndex, mocks.scrollerVisibleStartIndex + visibleItemCount);
};
watch(() => props.items, bindVisibleItems, { immediate: true });
expose({
updateVisibleItems: (itemsChanged?: boolean) => {
mocks.scrollerUpdateVisibleItems(itemsChanged);
mocks.scrollerCallOrder.push(`update:${(props.items[mocks.scrollerVisibleStartIndex] as { id?: string } | undefined)?.id ?? "missing"}`);
const viewportItems = props.items.slice(mocks.scrollerVisibleStartIndex, mocks.scrollerVisibleStartIndex + visibleItemCount) as Array<{ id?: string } | undefined>;
mocks.scrollerRefreshSnapshots.push({
length: props.items.length,
ids: Array.from(viewportItems, (item) => item?.id),
hasHoles: Array.from(viewportItems).some((item) => item === undefined),
});
// The real scroller keeps a keyed view pool when false. Rebind only
// when callers identify in-place item changes explicitly.
if (itemsChanged) bindVisibleItems();
layoutItemCount = props.items.length;
},
getScroll: () => ({ start: mocks.scrollerVisibleStartIndex * 30, end: (mocks.scrollerVisibleStartIndex + visibleItemCount) * 30 }),
findItemIndex: (offset: number) => Math.floor(offset / 30),
scrollToItem: (index: number, options?: { align?: string }) => {
mocks.scrollerScrollToItem(index, options);
mocks.scrollerCallOrder.push(`scroll:${(props.items[index] as { id?: string } | undefined)?.id ?? "missing"}`);
// A browser clamps scrollTop to the currently rendered spacer.
// The scroller's reactive total size reaches the DOM only after
// updateVisibleItems, so model that old-layout constraint here.
mocks.scrollerVisibleStartIndex = Math.min(index, Math.max(0, layoutItemCount - 1));
mocks.scrollerVisiblePositions.push(mocks.scrollerVisibleStartIndex);
bindVisibleItems();
},
});
return () => {
@@ -403,6 +429,26 @@ function redisKeyInfo(keyType = "json") {
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
}
function redisFlatRow(id: string, label: string, keyRaw = id) {
return {
id,
depth: 0,
node: {
kind: "leaf" as const,
id,
label,
fullKeyDisplay: label,
keyRaw,
db: 0,
keyType: "string",
ttl: -1,
size: 0,
valuePreview: "",
pathSegments: [label],
},
};
}
const completionCommands = [
{ name: "GET", group: "string", arity: 2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "GETEX", group: "string", arity: -2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
@@ -430,6 +476,10 @@ function resetApiMocks() {
mocks.queryResultMaxRows = 5000;
mocks.loadedTtl = -1;
mocks.scrollerItems.length = 0;
mocks.scrollerCallOrder.length = 0;
mocks.scrollerVisiblePositions.length = 0;
mocks.scrollerRefreshSnapshots.length = 0;
mocks.scrollerVisibleStartIndex = 0;
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
mocks.redisSetString.mockResolvedValue(undefined);
@@ -451,6 +501,14 @@ function resetApiMocks() {
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
}
function resetScrollerObservations() {
mocks.scrollerUpdateVisibleItems.mockClear();
mocks.scrollerScrollToItem.mockClear();
mocks.scrollerCallOrder.length = 0;
mocks.scrollerVisiblePositions.length = 0;
mocks.scrollerRefreshSnapshots.length = 0;
}
function mountBrowser(withDeleteDetails = false) {
const host = document.createElement("div");
document.body.append(host);
@@ -1794,29 +1852,37 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
});
describe("RedisKeyBrowser interrupted Fetch All", () => {
it("publishes Fetch All rows through the existing scroller array and explicitly refreshes its viewport", async () => {
it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => {
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
const itemsBeforeFetchAll = mocks.scrollerItems[mocks.scrollerItems.length - 1];
resetScrollerObservations();
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(document.body.textContent).toContain("buffered"));
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(itemsBeforeFetchAll);
const publishedItems = mocks.scrollerItems[mocks.scrollerItems.length - 1];
expect(publishedItems).toBe(itemsBeforeFetchAll);
expect(Array.isArray(publishedItems)).toBe(true);
expect(publishedItems).toHaveLength(2);
expect((publishedItems[0] as { id?: string } | undefined)?.id).toBeTruthy();
expect([...(publishedItems as Array<{ node: { label: string } }>)].map((row) => row.node.label).sort()).toEqual(["buffered", "initial"]);
expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledWith(true);
expect((publishedItems as Array<{ node: { label: string } }>).some((row) => row.node.label === "buffered")).toBe(true);
expect(document.body.textContent).toContain("initial");
expect(document.body.textContent).not.toContain("redis.stopFetchAll");
});
it("rebinds rendered row labels after multi-chunk in-place publication", async () => {
it("rebinds rendered labels from a complete large facade source without exposing holes", async () => {
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
const visibleRows = Array.from({ length: 25_001 }, (_, index) => {
const label = `next-${String(index).padStart(5, "0")}`;
@@ -1855,24 +1921,144 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
await vi.waitFor(() => expect(document.body.textContent).toContain("next-00000"));
await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll"));
expect(mocks.scrollerUpdateVisibleItems.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(1);
expect(mocks.scrollerUpdateVisibleItems.mock.calls.every(([itemsChanged]) => itemsChanged === true)).toBe(true);
expect(mocks.scrollerRefreshSnapshots[0]).toMatchObject({ length: 25_001, hasHoles: false });
expect(document.body.textContent).toContain("next-00049");
expect(document.body.textContent).not.toContain("initial");
});
it("restores a visible row by stable ID when final ordering moves it across publication chunks", async () => {
const initialKeys = [
{ key_display: "a-before", key_raw: "YS1iZWZvcmU=", key_type: "string", ttl: -1 },
{ key_display: "m-anchor", key_raw: "bS1hbmNob3I=", key_type: "string", ttl: -1 },
{ key_display: "z-after", key_raw: "ei1hZnRlcg==", key_type: "string", ttl: -1 },
];
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 4 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
const initialRows = mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ id: string }>;
const anchorRowId = initialRows[1]?.id;
expect(anchorRowId).toBeTruthy();
mocks.scrollerVisibleStartIndex = 1;
const visibleRows = Array.from({ length: 25_001 }, (_, index) => redisFlatRow(`next:${index}`, `next-${index}`));
visibleRows[25_000] = redisFlatRow(anchorRowId!, "m-anchor", initialKeys[1]!.key_raw);
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
flatKeys: [...initialKeys, buffered],
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
filteredKeyCount: 4,
treeIndex: null,
expandedGroupIds: new Set(),
visibleRows,
});
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(mocks.scrollerScrollToItem).toHaveBeenCalled());
expect(mocks.scrollerScrollToItem).toHaveBeenCalledWith(25_000, { align: "start" });
expect(mocks.scrollerVisiblePositions).toEqual([2, 25_000]);
expect(mocks.scrollerCallOrder).toEqual([`scroll:${anchorRowId}`, "update:next:2", `scroll:${anchorRowId}`, `update:${anchorRowId}`]);
expect(document.body.textContent).toContain("m-anchor");
});
it("does not move the viewport when its stable row anchor is absent from the final projection", async () => {
const initialKeys = [
{ key_display: "a-before", key_raw: "YS1iZWZvcmU=", key_type: "string", ttl: -1 },
{ key_display: "m-missing", key_raw: "bS1taXNzaW5n", key_type: "string", ttl: -1 },
];
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 3 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
mocks.scrollerVisibleStartIndex = 1;
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
flatKeys: [...initialKeys, buffered],
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
filteredKeyCount: 3,
treeIndex: null,
expandedGroupIds: new Set(),
visibleRows: [redisFlatRow("replacement", "replacement")],
});
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
});
it("prefers the latest visible stable row when the user scrolls between publication chunks", async () => {
const initialKeys = Array.from({ length: 20 }, (_, index) => ({
key_display: `initial-${String(index).padStart(2, "0")}`,
key_raw: `initial-raw-${index}`,
key_type: "string",
ttl: -1,
}));
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 21 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
const initialRows = mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ id: string }>;
const initialRowId = initialRows[0]!.id;
const latestAnchorRowId = initialRows[10]!.id;
const visibleRows = Array.from({ length: 25_001 }, (_, index) => redisFlatRow(`next:${index}`, `next-${index}`));
visibleRows[5] = redisFlatRow(latestAnchorRowId, "initial-10", initialKeys[10]!.key_raw);
visibleRows[25_000] = redisFlatRow(initialRowId, "initial-00", initialKeys[0]!.key_raw);
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
flatKeys: [...initialKeys, buffered],
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
filteredKeyCount: 21,
treeIndex: null,
expandedGroupIds: new Set(),
visibleRows,
});
const pendingFrames: FrameRequestCallback[] = [];
const originalRequestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
pendingFrames.push(callback);
return pendingFrames.length;
};
try {
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(pendingFrames).toHaveLength(1));
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
mocks.scrollerVisibleStartIndex = 10;
requiredElement<HTMLElement>(".redis-key-scroller").dispatchEvent(new Event("scroll"));
pendingFrames[0]!(0);
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(2));
expect(mocks.scrollerScrollToItem).toHaveBeenCalledTimes(2);
expect(mocks.scrollerScrollToItem).toHaveBeenCalledWith(5, { align: "start" });
expect(mocks.scrollerCallOrder).toEqual([`scroll:${latestAnchorRowId}`, `update:${latestAnchorRowId}`, `scroll:${latestAnchorRowId}`, `update:${latestAnchorRowId}`]);
} finally {
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("keeps stable Fetch All rows for metadata-only detail updates and exits them for no-expiry membership changes", async () => {
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
requiredElement<HTMLButtonElement>("[data-redis-no-expiry-filter]").click();
await settle();
resetScrollerObservations();
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull());
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
const stableItems = mocks.scrollerItems[mocks.scrollerItems.length - 1];
expect((stableItems as Array<{ node: { keyRaw?: string } }>).some((row) => row.node.keyRaw === buffered.key_raw)).toBe(true);
requiredElement<HTMLElement>(`[data-redis-leaf="${initial.key_raw}"]`).closest<HTMLElement>(".group")?.click();
await settle();
@@ -1884,9 +2070,9 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
mocks.loadedTtl = 60;
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
await settle();
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).not.toBe(stableItems);
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(stableItems);
expect(document.querySelector(`[data-redis-leaf="${initial.key_raw}"]`)).toBeNull();
expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull();
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { keyRaw?: string } }>).some((row) => row.node.keyRaw === buffered.key_raw)).toBe(true);
});
it("keeps the previously published rows when Fetch All is stopped during cooperative preparation", async () => {
@@ -1904,6 +2090,7 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
});
mountBrowser();
await settle();
resetScrollerObservations();
clickButtonWithText("redis.fetchAllKeys");
await buildStarted.promise;
@@ -1914,15 +2101,17 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
expect(document.body.textContent).toContain("initial");
expect(document.body.textContent).not.toContain("buffered");
expect(document.body.textContent).toContain("redis.fetchAllKeys");
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { label: string } }>).map((row) => row.node.label)).toEqual(["initial"]);
});
it("rolls back a partially published stable row buffer when Fetch All is stopped", async () => {
it("keeps the old facade source when Fetch All is stopped during cooperative anchor resolution", async () => {
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
mountBrowser();
await settle();
resetScrollerObservations();
const row = {
id: "leaf:0:buffered",
@@ -1962,7 +2151,8 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
try {
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(pendingFrames).toHaveLength(1));
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
clickButtonWithText("redis.stopFetchAll");
expect(pendingFrames).toHaveLength(1);
pendingFrames[0](0);
@@ -1971,6 +2161,9 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
expect(document.body.textContent).toContain("initial");
expect(document.body.textContent).not.toContain("buffered");
expect(document.body.textContent).toContain("redis.fetchAllKeys");
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { label: string } }>).map((item) => item.node.label)).toEqual(["initial"]);
expect(mocks.scrollerRefreshSnapshots.every((snapshot) => !snapshot.ids.includes(row.id))).toBe(true);
} finally {
window.requestAnimationFrame = originalRequestAnimationFrame;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
import { computed, markRaw, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
import type { CalendarDateTime } from "@internationalized/date";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue";
@@ -113,6 +113,8 @@ const fetchAllLoadedCount = ref(0);
const rootRef = ref<HTMLElement>();
const keyPaneRef = ref<HTMLElement>();
const redisKeyScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
let redisKeyScrollRevision = 0;
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref("");
@@ -405,26 +407,59 @@ watch(activeCreateKeyTypeHelp, () => {
const regularVisibleRows = computed(() => {
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
});
// Fetch All keeps this raw array identity bound to RecycleScroller. Replacing
// the prop array makes vue-virtual-scroller slice and inspect every row key;
// bounded in-place publication plus updateVisibleItems avoids that O(N) turn.
const fetchAllVisibleRows = shallowRef<RedisKeyTreeRow[]>([]);
let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = [];
function facadeArrayIndex(property: PropertyKey): number {
if (typeof property !== "string" || property === "") return -1;
const index = Number(property);
return Number.isInteger(index) && index >= 0 && String(index) === property ? index : -1;
}
// Keep one raw Array-shaped identity bound throughout Fetch All. Array methods
// such as slice use both indexed reads and `has`, so proxy both operations;
// switching the complete backing snapshot is then O(1) and cannot expose holes.
const fetchAllVisibleRowsFacade = markRaw(
new Proxy([] as RedisKeyTreeRow[], {
get(target, property, receiver) {
if (property === "length") return fetchAllVisibleRowsSource.length;
const index = facadeArrayIndex(property);
return index >= 0 ? fetchAllVisibleRowsSource[index] : Reflect.get(target, property, receiver);
},
has(target, property) {
const index = facadeArrayIndex(property);
return index >= 0 ? index < fetchAllVisibleRowsSource.length : Reflect.has(target, property);
},
}),
);
const fetchAllVisibleRows = shallowRef<RedisKeyTreeRow[]>(fetchAllVisibleRowsFacade);
const fetchAllVisibleRowsActive = ref(false);
const visibleRows = computed(() => (fetchAllVisibleRowsActive.value ? fetchAllVisibleRows.value : regularVisibleRows.value));
const redisKeyScrollerRows = fetchAllVisibleRowsFacade;
watch(
regularVisibleRows,
(rows) => {
if (fetchAllVisibleRowsActive.value) return;
fetchAllVisibleRowsSource = rows;
void nextTick(() => {
if (!fetchAllVisibleRowsActive.value && fetchAllVisibleRowsSource === rows) refreshRedisKeyScroller();
});
},
{ immediate: true },
);
function activateFetchAllVisibleRows() {
if (fetchAllVisibleRowsActive.value) return;
// Return the exact currently-bound identity when the override is enabled, so
// entering Fetch All does not itself trigger the scroller's items watcher.
fetchAllVisibleRows.value = regularVisibleRows.value;
fetchAllVisibleRowsSource = regularVisibleRows.value;
fetchAllVisibleRowsActive.value = true;
}
function deactivateFetchAllVisibleRows() {
if (!fetchAllVisibleRowsActive.value) return;
fetchAllVisibleRowsActive.value = false;
fetchAllVisibleRows.value = [];
fetchAllVisibleRowsSource = regularVisibleRows.value;
fetchAllFilteredKeyCount.value = null;
refreshRedisKeyScroller();
}
function rollbackFetchAllPublication() {
@@ -1007,7 +1042,12 @@ async function maybeAutoLoadMoreRedisKeys() {
function onRedisKeyScroll(event: Event) {
const scroller = event.target;
if (!(scroller instanceof HTMLElement) || redisInfiniteScrollFrame) return;
if (!(scroller instanceof HTMLElement)) return;
if (isFetchingAll.value && fetchAllVisibleRowsActive.value) {
redisKeyScrollRevision++;
latestRedisKeyScrollAnchor = captureRedisKeyViewportAnchor(redisKeyScrollRevision);
}
if (redisInfiniteScrollFrame) return;
redisInfiniteScrollFrame = requestAnimationFrame(() => {
redisInfiniteScrollFrame = 0;
const shouldLoad = shouldLoadMoreRedisKeys({
@@ -1032,36 +1072,114 @@ const FETCH_ALL_SCAN_COUNT = 50000;
const FETCH_ALL_BATCH_ITERATIONS = 8;
const FETCH_ALL_PUBLISH_CHUNK_SIZE = 25_000;
type RedisKeyViewportAnchor = {
rowId: string;
rowIndex: number;
scrollRevision: number;
};
type RedisKeyScroller = {
getScroll: () => { start: number; end: number };
findItemIndex: (offset: number) => number;
scrollToItem: (index: number, options?: { align?: "start" | "center" | "end" | "nearest"; smooth?: boolean; offset?: number }) => void;
updateVisibleItems?: (itemsChanged: boolean, checkPositionDiff?: boolean) => unknown;
$forceUpdate?: () => void;
};
function yieldForRedisKeyBrowserPaint(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function redisKeyScroller(): RedisKeyScroller | null {
return redisKeyScrollerRef.value as unknown as RedisKeyScroller | null;
}
function captureRedisKeyViewportAnchor(scrollRevision = redisKeyScrollRevision): RedisKeyViewportAnchor | null {
const scroller = redisKeyScroller();
if (!scroller) return null;
const rowIndex = scroller.findItemIndex(scroller.getScroll().start);
if (!Number.isInteger(rowIndex) || rowIndex < 0) return null;
const rowId = fetchAllVisibleRows.value[rowIndex]?.id;
return rowId ? { rowId, rowIndex, scrollRevision } : null;
}
function refreshRedisKeyScroller() {
const scroller = redisKeyScrollerRef.value as unknown as { updateVisibleItems?: (force?: boolean) => unknown } | null;
// Entries changed in place while the array identity stayed stable. Tell the
const scroller = redisKeyScroller();
// The facade source changed while its array identity stayed stable. Tell the
// scroller to invalidate its keyed view pool for the currently rendered
// range; `false` preserves old key/view mappings and can combine row labels.
scroller?.updateVisibleItems?.(true);
// The method is public on RecycleScroller, but keep the structural guard for
// lightweight renderers (including tests) that do not implement scrolling.
if (typeof scroller?.updateVisibleItems === "function") {
scroller.updateVisibleItems(true);
} else {
scroller?.$forceUpdate?.();
}
}
function fetchAllPublicationIsCurrent(requestId: number): boolean {
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
}
async function resolveFetchAllViewportAnchor(rows: readonly RedisKeyTreeRow[], requestId: number): Promise<{ anchor: RedisKeyViewportAnchor; rowIndex: number } | null | undefined> {
let anchor = captureRedisKeyViewportAnchor();
if (!anchor) return null;
while (fetchAllPublicationIsCurrent(requestId)) {
let rowIndex = -1;
let anchorChanged = false;
for (let offset = 0; offset < rows.length; offset += FETCH_ALL_PUBLISH_CHUNK_SIZE) {
if (!fetchAllPublicationIsCurrent(requestId)) return undefined;
const end = Math.min(offset + FETCH_ALL_PUBLISH_CHUNK_SIZE, rows.length);
for (let index = offset; index < end; index++) {
if (rows[index]?.id === anchor.rowId) {
rowIndex = index;
break;
}
}
if (rowIndex >= 0) break;
if (end < rows.length) await yieldForRedisKeyBrowserPaint();
const latest = latestRedisKeyScrollAnchor;
if (latest && latest.scrollRevision > anchor.scrollRevision) {
anchor = latest;
anchorChanged = true;
break;
}
}
const latest = latestRedisKeyScrollAnchor;
if (latest && latest.scrollRevision > anchor.scrollRevision) {
anchor = latest;
continue;
}
if (!anchorChanged) return rowIndex >= 0 ? { anchor, rowIndex } : null;
}
return undefined;
}
async function publishFetchAllVisibleRows(rows: readonly RedisKeyTreeRow[], requestId: number): Promise<boolean> {
const target = fetchAllVisibleRows.value;
if (rows.length === 0) {
target.length = 0;
const anchorResolution = await resolveFetchAllViewportAnchor(rows, requestId);
if (anchorResolution === undefined) return false;
if (!fetchAllPublicationIsCurrent(requestId)) return false;
// The facade stays bound while its complete backing source switches. If an
// anchor survived, install the source and reposition before refreshing the
// pool in this same browser turn, making the visual handoff atomic.
fetchAllVisibleRowsSource = rows;
if (anchorResolution) redisKeyScroller()?.scrollToItem(anchorResolution.rowIndex, { align: "start" });
refreshRedisKeyScroller();
if (anchorResolution) {
// updateVisibleItems updates the scroller's reactive total size, but Vue
// applies the corresponding spacer height on its next DOM flush. A target
// beyond the old list's scroll range is clamped by the browser until that
// happens, so repeat the bounded viewport update after nextTick. Both
// flushes stay in the same event-loop turn (there is no paint-capable
// yield), preserving an atomic visual handoff.
await nextTick();
if (!fetchAllPublicationIsCurrent(requestId)) return false;
redisKeyScroller()?.scrollToItem(anchorResolution.rowIndex, { align: "start" });
refreshRedisKeyScroller();
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
}
for (let offset = 0; offset < rows.length; offset += FETCH_ALL_PUBLISH_CHUNK_SIZE) {
if (requestId !== searchRequestId || !redisBrowserIsActive || fetchAllStopRequested.value) return false;
const end = Math.min(offset + FETCH_ALL_PUBLISH_CHUNK_SIZE, rows.length);
for (let index = offset; index < end; index++) target[index] = rows[index];
if (end === rows.length) target.length = rows.length;
refreshRedisKeyScroller();
// Yield only when another bounded publication unit remains.
if (end < rows.length) await yieldForRedisKeyBrowserPaint();
}
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
return fetchAllPublicationIsCurrent(requestId);
}
async function fetchAll(): Promise<boolean> {
@@ -2651,7 +2769,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div v-else-if="noExpiryOnly && visibleRows.length === 0" class="flex-1 flex items-center justify-center text-muted-foreground text-xs p-4 text-center">
{{ t("redis.noExpiryKeysEmpty") }}
</div>
<RecycleScroller v-else ref="redisKeyScrollerRef" class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll" @resize="maybeAutoLoadMoreRedisKeys">
<RecycleScroller v-else ref="redisKeyScrollerRef" class="redis-key-scroller flex-1" :items="redisKeyScrollerRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll" @resize="maybeAutoLoadMoreRedisKeys">
<template #default="{ item: row }">
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
<div