diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 0b94d39c9a..0ec23a2681 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -1851,6 +1851,50 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { }); }); +describe("RedisKeyBrowser KeepAlive scan budget (issue #7779)", () => { + it("does not replenish an exhausted automatic budget after deactivate/reactivate and resize", async () => { + const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 1000 : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 90 : 0; + }, + }); + + try { + mocks.infiniteScroll = true; + mocks.redisScanPageSize = 1000; + let call = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + call++; + return Promise.resolve({ cursor: cursor + 1, keys: call === 1 ? [{ key_display: "seed", key_raw: "c2VlZA==", key_type: "string", ttl: -1 }] : [], total_keys: 5_000_000 }); + }); + + const browser = mountKeptAliveBrowser(); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(8)); + await settle(); + const callsAtBudget = mocks.redisScanKeysBatch.mock.calls.length; + + await browser.deactivate(); + await browser.activate(); + requiredElement(".redis-key-scroller").dispatchEvent(new Event("resize")); + await settle(); + + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(callsAtBudget); + } finally { + if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + if (originalScrollHeight) Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight); + } + }); +}); + describe("RedisKeyBrowser interrupted Fetch All", () => { 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 }; diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts index 2ca97e7cc9..9df3913725 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -1,12 +1,14 @@ // @vitest-environment happy-dom -import { createApp, nextTick } from "vue"; +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"; const mocks = vi.hoisted(() => ({ + ensureConnected: vi.fn(), redisScanKeysBatch: vi.fn(), + redisScanValues: vi.fn(), redisGetValue: vi.fn(), redisSetString: vi.fn(), redisJsonSet: vi.fn(), @@ -34,6 +36,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/backend/api", () => ({ redisScanKeysBatch: mocks.redisScanKeysBatch, + redisScanValues: mocks.redisScanValues, redisGetValue: mocks.redisGetValue, redisSetString: mocks.redisSetString, redisJsonSet: mocks.redisJsonSet, @@ -53,7 +56,7 @@ vi.mock("@/lib/backend/api", () => ({ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ - ensureConnected: vi.fn().mockResolvedValue(undefined), + ensureConnected: mocks.ensureConnected, getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }), updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs, @@ -276,6 +279,45 @@ function mountBrowser() { return host; } +function mountKeptAliveBrowser(onError?: (error: unknown) => void) { + const active = ref(true); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(KeepAlive, null, { + default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null), + }); + }, + }), + ); + if (onError) app.config.errorHandler = onError; + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + return { + host, + async deactivate() { + active.value = false; + await settleThoroughly(); + }, + async activate() { + active.value = true; + await settleThoroughly(); + }, + }; +} + +function unmountBrowser(host: HTMLElement) { + const index = mountedApps.findIndex((mounted) => mounted.host === host); + expect(index, "mounted browser").toBeGreaterThanOrEqual(0); + const [mounted] = mountedApps.splice(index, 1); + mounted!.unmount(); + host.remove(); +} + async function settle() { await nextTick(); await Promise.resolve(); @@ -284,13 +326,25 @@ async function settle() { await nextTick(); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; +} + function resetApiMocks() { vi.clearAllMocks(); + mocks.ensureConnected.mockResolvedValue(undefined); mocks.redisScanPageSize = 100; mocks.infiniteScroll = true; mocks.queryResultMaxRowsEnabled = true; mocks.queryResultMaxRows = 5000; mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); + mocks.redisScanValues.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); } // A short, un-collapsed tree (a real DB has millions of keys folded into a @@ -450,6 +504,308 @@ function keyInfo(rawKey: string): RedisKeyInfo { return { key_display: rawKey, key_raw: rawKey, key_type: "string", ttl: -1, size: 0, value_preview: "" }; } +async function searchByValue(host: HTMLElement, mode: "value" | "all" = "value") { + const modeIndex = mode === "value" ? 1 : 2; + host.querySelectorAll(".redis-search-mode-button")[modeIndex]?.click(); + await settle(); + const input = host.querySelector("[data-redis-search-input]"); + expect(input, "redis search input").toBeDefined(); + input!.value = "needle"; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settleThoroughly(); +} + +function clickLoadMore(host: HTMLElement) { + const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("redis.loadMoreKeys") && !candidate.disabled); + expect(button, "load more button").toBeDefined(); + button!.click(); +} + +function clickFetchAll(host: HTMLElement) { + const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("redis.fetchAllKeys") && !candidate.disabled); + expect(button, "fetch all button").toBeDefined(); + button!.click(); +} + +describe("RedisKeyBrowser bounded value search (issue #7779)", () => { + it.each([ + ["value", false], + ["all", true], + ] as const)("loads only one initial %s page and clamps COUNT", async (mode, searchBoth) => { + mocks.redisScanPageSize = 5000; + mocks.redisScanValues.mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 }).mockResolvedValue({ cursor: 0, keys: [keyInfo("unexpected")], total_keys: 0 }); + + const host = mountBrowser(); + await settleThoroughly(); + await searchByValue(host, mode); + + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + expect(mocks.redisScanValues).toHaveBeenLastCalledWith("connection", 0, 0, "*", "needle", 100, searchBoth); + expect(host.textContent).toContain("redis.loadMoreKeys"); + }); + + it("advances exactly one page per Load more or real scroll and preserves the opaque cursor", async () => { + stubNonOverflowingViewport(); + mocks.redisScanPageSize = 1000; + mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 41, keys: [keyInfo("match")], total_keys: 5_000_000 }); + if (cursor === 41) return Promise.resolve({ cursor: 73, keys: [keyInfo("match")], total_keys: 0 }); + return Promise.resolve({ cursor: 99, keys: [keyInfo("sparse")], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + await searchByValue(host); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize")); + await settleThoroughly(); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + + clickLoadMore(host); + await settleThoroughly(); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(2); + expect(mocks.redisScanValues.mock.calls[1]?.[2]).toBe(41); + + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll")); + await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(3)); + expect(mocks.redisScanValues.mock.calls[2]?.[2]).toBe(73); + expect(mocks.redisScanValues.mock.calls.every((call) => (call[5] as number) <= 100)).toBe(true); + expect(host.textContent).toContain("sparse"); + }); + + it("keeps explicit Fetch all interruptible by clamping every value page to COUNT 100", async () => { + mocks.redisScanPageSize = 10_000; + mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 11, keys: [], total_keys: 5_000_000 }); + if (cursor === 11) return Promise.resolve({ cursor: 12, keys: [], total_keys: 0 }); + return Promise.resolve({ cursor: 0, keys: [keyInfo("last")], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + await searchByValue(host); + clickFetchAll(host); + await settleThoroughly(); + + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 11, 12]); + expect(mocks.redisScanValues.mock.calls.every((call) => call[5] === 100)).toBe(true); + }); + + it("does not retry a rejected value continuation", async () => { + stubNonOverflowingViewport(); + mocks.redisScanValues.mockResolvedValueOnce({ cursor: 31, keys: [keyInfo("match")], total_keys: 5_000_000 }).mockRejectedValue(new Error("value scan failed")); + + const host = mountBrowser(); + await settleThoroughly(); + await searchByValue(host); + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll")); + await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(2)); + await settleThoroughly(); + + expect(mocks.toast).toHaveBeenCalledTimes(1); + await settleThoroughly(10); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(2); + }); +}); + +describe("RedisKeyBrowser KeepAlive empty scan pages (issue #7779)", () => { + it.each(["value", "all"] as const)("retains an empty %s scan cursor across tab switches", async (mode) => { + mocks.infiniteScroll = false; + mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 41, keys: [], total_keys: 5_000_000 }); + if (cursor === 41) return Promise.resolve({ cursor: 73, keys: [], total_keys: 0 }); + return Promise.resolve({ cursor: 0, keys: [keyInfo("match")], total_keys: 0 }); + }); + const browser = mountKeptAliveBrowser(); + await settleThoroughly(); + await searchByValue(browser.host, mode); + clickLoadMore(browser.host); + await settleThoroughly(); + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41]); + + await browser.deactivate(); + await browser.activate(); + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41]); + + clickLoadMore(browser.host); + await settleThoroughly(); + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41, 73]); + expect(browser.host.textContent).toContain("match"); + }); + + it.each(["value", "all"] as const)("starts the edited %s query from zero after pausing its debounce", async (mode) => { + mocks.infiniteScroll = false; + mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, _cursor: number, _pattern: string, query: string) => Promise.resolve(query === "needle" ? { cursor: 41, keys: [], total_keys: 5_000_000 } : { cursor: 0, keys: [keyInfo("updated-match")], total_keys: 1 })); + const browser = mountKeptAliveBrowser(); + await settleThoroughly(); + await searchByValue(browser.host, mode); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + + const input = browser.host.querySelector("[data-redis-search-input]")!; + input.value = "updated"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + await browser.deactivate(); + await browser.activate(); + + expect(mocks.redisScanValues.mock.calls.map((call) => [call[2], call[4]])).toEqual([ + [0, "needle"], + [0, "updated"], + ]); + expect(browser.host.textContent).toContain("updated-match"); + }); + + it("keeps the edited query pending when connection recovery finishes while inactive", async () => { + mocks.infiniteScroll = false; + mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, _cursor: number, _pattern: string, query: string) => Promise.resolve(query === "needle" ? { cursor: 41, keys: [], total_keys: 5_000_000 } : { cursor: 0, keys: [keyInfo("updated-match")], total_keys: 1 })); + const browser = mountKeptAliveBrowser(); + await settleThoroughly(); + await searchByValue(browser.host); + const input = browser.host.querySelector("[data-redis-search-input]")!; + input.value = "updated"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + await browser.deactivate(); + + const connectionRecovery = deferred(); + mocks.ensureConnected.mockReturnValueOnce(connectionRecovery.promise); + await browser.activate(); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + await browser.deactivate(); + connectionRecovery.resolve(); + await settleThoroughly(); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + + await browser.activate(); + expect(mocks.redisScanValues.mock.calls.map((call) => [call[2], call[4]])).toEqual([ + [0, "needle"], + [0, "updated"], + ]); + expect(browser.host.textContent).toContain("updated-match"); + }); + + it("retries a failed refresh on activation even when the previous empty page had more keys", async () => { + const onError = vi.fn(); + const failure = new Error("refresh failed"); + mocks.infiniteScroll = false; + mocks.redisScanValues + .mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 }) + .mockRejectedValueOnce(failure) + .mockResolvedValue({ cursor: 0, keys: [keyInfo("recovered")], total_keys: 1 }); + const browser = mountKeptAliveBrowser(onError); + await settleThoroughly(); + await searchByValue(browser.host); + + const refresh = Array.from(browser.host.querySelectorAll("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title")); + expect(refresh, "refresh button").toBeDefined(); + refresh!.click(); + await settleThoroughly(); + expect(onError).toHaveBeenCalledWith(failure, expect.anything(), expect.anything()); + + await browser.deactivate(); + await browser.activate(); + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 0, 0]); + expect(browser.host.textContent).toContain("recovered"); + }); + + it("retries an interrupted refresh without accepting its late empty page", async () => { + const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + mocks.infiniteScroll = false; + mocks.redisScanValues + .mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 }) + .mockReturnValueOnce(pending.promise) + .mockResolvedValue({ cursor: 0, keys: [keyInfo("recovered")], total_keys: 1 }); + const browser = mountKeptAliveBrowser(); + await settleThoroughly(); + await searchByValue(browser.host); + + const refresh = Array.from(browser.host.querySelectorAll("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title")); + expect(refresh, "refresh button").toBeDefined(); + refresh!.click(); + await settleThoroughly(); + expect(mocks.redisScanValues).toHaveBeenCalledTimes(2); + + await browser.deactivate(); + pending.resolve({ cursor: 91, keys: [], total_keys: 0 }); + await settleThoroughly(); + await browser.activate(); + expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 0, 0]); + expect(browser.host.textContent).toContain("recovered"); + }); +}); + +describe("RedisKeyBrowser continuation ownership (issue #7779)", () => { + it("disables refresh while the current page is loading", async () => { + const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + mocks.redisScanKeysBatch.mockReturnValue(pending.promise); + + const host = mountBrowser(); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1)); + const refresh = Array.from(host.querySelectorAll("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title")); + expect(refresh, "refresh button").toBeDefined(); + expect(refresh!.disabled).toBe(true); + refresh!.click(); + await settle(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + + pending.resolve({ cursor: 0, keys: [], total_keys: 0 }); + await settleThoroughly(); + }); + + it("blocks an old cursor while an Enter-only key pattern is unsubmitted", async () => { + stubNonOverflowingViewport(); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 5000 : 0; + }, + }); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, _cursor: number, pattern: string) => { + if (pattern === "*") return Promise.resolve({ cursor: 41, keys: [keyInfo("seed")], total_keys: 5_000_000 }); + return Promise.resolve({ cursor: 0, keys: [keyInfo("new")], total_keys: 1 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + const input = host.querySelector("[data-redis-search-input]")!; + input.value = "new"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize")); + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => setTimeout(resolve, 20)); + await settleThoroughly(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2)); + expect(mocks.redisScanKeysBatch.mock.calls[1]?.slice(2, 4)).toEqual([0, "new"]); + }); + + it("discards a late value page after unmount without scheduling a successor", async () => { + const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + mocks.redisScanValues.mockReturnValue(pending.promise); + + const host = mountBrowser(); + await settleThoroughly(); + await searchByValue(host); + await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(1)); + + mocks.updateRedisDbKeyStats.mockClear(); + unmountBrowser(host); + pending.resolve({ cursor: 91, keys: [keyInfo("stale")], total_keys: 5_000_000 }); + await settleThoroughly(); + + expect(mocks.redisScanValues).toHaveBeenCalledTimes(1); + expect(mocks.updateRedisDbKeyStats).not.toHaveBeenCalled(); + }); +}); + // `iterations` (aka `max_iterations`) is the 6th positional arg the frontend // sends to `redisScanKeysBatch` — the same unit the backend spends as real // Redis SCAN calls (see `crates/dbx-core/src/db/redis_driver.rs`). diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts index f25ba747fb..be92e05ba3 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { createApp, nextTick } from "vue"; +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"; @@ -273,6 +273,37 @@ function mountBrowser() { return host; } +function mountKeptAliveBrowser() { + const active = ref(true); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(KeepAlive, null, { + default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null), + }); + }, + }), + ); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + + return { + host, + async deactivate() { + active.value = false; + await settle(); + }, + async activate() { + active.value = true; + await settle(); + }, + }; +} + async function settle() { await nextTick(); await Promise.resolve(); @@ -285,6 +316,16 @@ async function settleThoroughly(rounds = 40) { for (let i = 0; i < rounds; i++) await settle(); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; +} + function resetApiMocks() { vi.clearAllMocks(); mocks.redisScanPageSize = 100; @@ -523,6 +564,40 @@ describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => { expect(leafVisible(host, "grp:a")).toBe(true); }); + it("rotates Load more continuation between pending expanded subtrees", async () => { + stubOverflowingViewport(); + const callsByGroup = { aa: 0, bb: 0 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "aa:*" || pattern === "bb:*") { + callsByGroup[pattern === "aa:*" ? "aa" : "bb"]++; + return Promise.resolve({ cursor: cursor + 1, keys: [], total_keys: 0 }); + } + if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("aa:a"), keyInfo("bb:a")], total_keys: 10_000 }); + return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + host.querySelectorAll("[data-redis-group]")[0]!.parentElement!.parentElement!.click(); + await settleThoroughly(); + host.querySelectorAll("[data-redis-group]")[1]!.parentElement!.parentElement!.click(); + await settleThoroughly(); + expect(callsByGroup).toEqual({ aa: 7, bb: 7 }); + + clickLoadMore(host); + await settleThoroughly(); + expect(callsByGroup).toEqual({ aa: 14, bb: 7 }); + clickLoadMore(host); + await settleThoroughly(); + expect(callsByGroup).toEqual({ aa: 14, bb: 14 }); + + // Each resumed group retains its own completed pass's cursor. + for (const pattern of ["aa:*", "bb:*"]) { + const calls = mocks.redisScanKeysBatch.mock.calls.filter((call: unknown[]) => call[3] === pattern); + expect(calls[SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS][2]).toBe(SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS); + } + }); + it("resumes a partially filled subtree when the group is re-expanded", async () => { stubOverflowingViewport(); let subtreeCall = 0; @@ -553,4 +628,175 @@ describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => { expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1); expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS][2]).toBe(3000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); }); + + it("does not resume a collapsed pending subtree from a main Load more", async () => { + stubOverflowingViewport(); + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + return Promise.resolve({ cursor: 4000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), 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(); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + expandFirstGroupRow(host); + clickLoadMore(host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS]?.[2]).toBe(4000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); + }); + + it("does not grant a pending subtree another pass from automatic main fill", async () => { + stubOverflowingViewport(); + let mainCalls = 0; + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + return Promise.resolve({ cursor: 5000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 }); + } + mainCalls++; + if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 5_000_000 }); + return Promise.resolve({ cursor: cursor + 1, keys: [], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 90 : 100; + }, + }); + host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize")); + await settleThoroughly(); + + expect(mainCalls).toBeGreaterThan(1); + expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + }); + + it("retains the last applied subtree cursor when KeepAlive invalidates an in-flight resume", async () => { + stubOverflowingViewport(); + const inFlightResume = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + if (subtreeCall <= SUBTREE_FILL_MAX_CALLS_BY_KEYS) { + return Promise.resolve({ cursor: 6000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 }); + } + if (subtreeCall === SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1) return inFlightResume.promise; + return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:resumed")], 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 browser = mountKeptAliveBrowser(); + await settleThoroughly(); + expandFirstGroupRow(browser.host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + clickLoadMore(browser.host); + await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1)); + expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS]?.[2]).toBe(6000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + await browser.deactivate(); + inFlightResume.resolve({ cursor: 7000, keys: [keyInfo("grp:stale")], total_keys: 0 }); + await settleThoroughly(); + await browser.activate(); + + expandFirstGroupRow(browser.host); + expandFirstGroupRow(browser.host); + await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 2)); + expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1]?.[2]).toBe(6000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); + expect(leafVisible(browser.host, "grp:stale")).toBe(false); + }); + + it("stops an active subtree after collapse and resumes from its in-flight cursor", async () => { + stubOverflowingViewport(); + const firstSubtreePage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*" && cursor === 0) return firstSubtreePage.promise; + if (pattern === "grp:*") return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:resumed")], 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(); + expandFirstGroupRow(host); + await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(1)); + expandFirstGroupRow(host); + firstSubtreePage.resolve({ cursor: 57, keys: [], total_keys: 0 }); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(1); + + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(2); + expect(subtreeCalls()[1]?.[2]).toBe(57); + }); + + it.each(["resolves", "rejects"] as const)("keeps a newer same-group fill owned when a stale fill %s", async (outcome) => { + stubOverflowingViewport(); + const oldFill = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const currentFill = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + let mainCalls = 0; + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, _cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + if (subtreeCall === 1) return oldFill.promise; + return currentFill.promise; + } + mainCalls++; + return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + expandFirstGroupRow(host); + await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(1)); + + const refresh = Array.from(host.querySelectorAll("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title")); + expect(refresh, "refresh button").toBeDefined(); + refresh!.click(); + await vi.waitFor(() => expect(mainCalls).toBeGreaterThanOrEqual(2)); + await settleThoroughly(); + expandFirstGroupRow(host); + expandFirstGroupRow(host); + await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(2)); + + if (outcome === "resolves") oldFill.resolve({ cursor: 88, keys: [keyInfo("grp:stale")], total_keys: 0 }); + else oldFill.reject(new Error("stale subtree failure")); + await settleThoroughly(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(leafVisible(host, "grp:stale")).toBe(false); + + expandFirstGroupRow(host); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls()).toHaveLength(2); + + currentFill.resolve({ cursor: 0, keys: [keyInfo("grp:current")], total_keys: 0 }); + await settleThoroughly(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 282ed654e8..4ef5911ad1 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -196,12 +196,14 @@ interface ScanIterationBudget { // actual SCAN iterations (the same unit as the `max_iterations` sent to the // backend), decremented by every backend call the automatic path makes — // regardless of how many pages/keys those calls span — and stop deterministically -// the moment it's spent. Reset alongside the rest of the per-operation state -// in `invalidateScanRequests`. An explicit "Load more" click or a real scroll +// the moment it's spent. Reset for genuine new load/search/scope operations; +// KeepAlive deactivation only invalidates ownership so it cannot replenish the +// same retained operation. An explicit "Load more" click or a real scroll // event is a single user-triggered request and keeps its own uncapped // per-call budget (see `fetchScanPage`); only the automatic follow-up check // they hand off to afterward is constrained by this shared budget. const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50; +const REDIS_VALUE_SCAN_COUNT = 100; let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; let redisBrowserIsActive = true; let reloadKeysOnActivation = false; @@ -224,12 +226,14 @@ let fetchAllPublicationRollback: null | { } = null; // 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。 const subtreeFilledGroupIds = new Set(); -// 补扫停在预算上限、尚未扫尽的分组:保存继续扫描所需的 SCAN 游标,供 -// “加载更多”/滚动按展开顺序续扫(见 resumePendingGroupSubtrees);同样在 -// loadKeys 重置。 +// 尚未扫尽的分组:保存已应用的最新 SCAN 游标,包括下一个请求还在飞行中时。 +// 这样预算停止、折叠或 KeepAlive 失效都能从已确认的位置续扫;“加载更多”/ +// 滚动按展开顺序续扫(见 resumePendingGroupSubtrees)。loadKeys 会重置该状态。 const subtreePendingGroupCursors = new Map(); -// 正在补扫中的分组:避免展开与续扫并发重复扫同一子树。 -const subtreeFillingGroupIds = new Set(); +// 正在补扫中的分组及其操作所有权:避免展开与续扫并发重复扫同一子树,并防止 +// 旧 generation 的 catch/finally 清掉或覆盖同 id 的新补扫。 +const subtreeFillOperations = new Map(); +let subtreeFillOperationId = 0; // 刷新前已展开分组的快照(#7173):刷新首屏重建树时,本轮尚未扫到的分组会被 // rebuildTree 从 expandedGroupIds 中裁掉;后续 load-more 页面让这些分组重新 // 出现时,用该快照恢复展开状态。SCAN 游标归零(本轮已扫尽,仍未出现的分组 @@ -772,7 +776,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) { if (checkedKeys.value.size > 0) selectionEpoch.value++; } -function invalidateScanRequests(): number { +function invalidateScanRequests(resetAutoLoadBudget = true): number { rollbackFetchAllPublication(); deactivateFetchAllVisibleRows(); // Keep invalidation authoritative even when the old Fetch All continuation @@ -784,7 +788,8 @@ function invalidateScanRequests(): number { searchRequestId++; loadMoreOperationId++; loadingMore.value = false; - autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; + subtreeFillOperations.clear(); + if (resetAutoLoadBudget) autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; return searchRequestId; } @@ -799,7 +804,7 @@ function isCurrentScanOperation(requestId: number, operationId?: number): boolea async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise { const pageSize = redisScanPageSize.value; if (isValueSearchMode.value) { - return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all"); + return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, Math.min(pageSize, REDIS_VALUE_SCAN_COUNT), searchMode.value === "all"); } // Keep each backend call small so a changed search can cancel between calls. @@ -848,7 +853,7 @@ async function fetchScanBatchPage(maxIterations: number, options: { count?: numb const pageSize = options.count ?? redisScanPageSize.value; // Value search cannot be batched because each key requires a GET. if (isValueSearchMode.value) { - return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all"); + return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, Math.min(pageSize, REDIS_VALUE_SCAN_COUNT), searchMode.value === "all"); } return api.redisScanKeysBatch(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize, maxIterations, options.includeTypes ?? false); } @@ -922,15 +927,11 @@ async function scanNextPage(requestId = searchRequestId, operationId?: number, i return true; } -async function streamValueSearch(requestId: number) { - while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) { - const applied = await scanNextPage(requestId); - if (!applied) return; - } -} - async function loadKeys() { if (!redisBrowserIsActive) return; + // Consume a deferred reload only once an active load actually starts; a + // connection check may finish after KeepAlive has paused the browser again. + reloadKeysOnActivation = false; if (searchTimer) clearTimeout(searchTimer); searchTimer = null; searchPending.value = false; @@ -944,7 +945,7 @@ async function loadKeys() { positiveTtlKeyRaws.clear(); subtreeFilledGroupIds.clear(); subtreePendingGroupCursors.clear(); - subtreeFillingGroupIds.clear(); + subtreeFillOperations.clear(); replaceFlatKeyRecords([]); treeKeys.value = []; treeIndex = null; @@ -969,9 +970,6 @@ async function loadKeys() { } const initialScanBudget = isFuzzyKeySearch.value ? autoLoadBudget : undefined; const applied = await scanNextPage(requestId, undefined, initialScanBudget); - if (applied && isValueSearchMode.value) { - await streamValueSearch(requestId); - } succeeded = applied; } finally { if (requestId === searchRequestId) { @@ -1008,9 +1006,11 @@ async function loadMore(iterationBudget?: ScanIterationBudget) { // automatic-fill check as everywhere else instead of relying on the user to // notice and click again. if (applied && isCurrentScanOperation(requestId, operationId)) { - // 主页面推进的同时,按展开顺序续扫一个停在预算上限的子树(#7918): - // 巨大分组靠“加载更多”/滚动逐段补全,而不是一次展开就扫尽。 - resumePendingGroupSubtrees(requestId); + if (!iterationBudget) { + // 只有用户点击“加载更多”或真实滚动时,才按展开顺序续扫一个停在预算上限 + // 的子树;短视口自动主补页不得额外获得一份独立子树预算。 + resumePendingGroupSubtrees(requestId); + } void maybeAutoLoadMoreRedisKeys(); } } @@ -1026,6 +1026,9 @@ async function loadMore(iterationBudget?: ScanIterationBudget) { // scroll handler already uses. async function maybeAutoLoadMoreRedisKeys() { await nextTick(); + // Value/all pages load per-key metadata and values. Layout changes must not + // turn a sparse query into an implicit background scan. + if (isValueSearchMode.value) return; // Unique visible/loaded keys are a poor stop condition on their own: an // empty, all-duplicate, or sparsely-matching page grows that count by ~0, // so relying on it alone lets a short viewport turn an ordinary tree load @@ -1339,40 +1342,56 @@ function mergeScannedKeys(newKeys: RedisKeyInfo[]) { async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) { if (subtreeFilledGroupIds.has(group.id)) return; - if (subtreeFillingGroupIds.has(group.id)) return; - subtreeFillingGroupIds.add(group.id); + if (subtreeFillOperations.has(group.id)) return; + const operationId = ++subtreeFillOperationId; + subtreeFillOperations.set(group.id, operationId); // 续扫从上次停在的游标继续,避免把已扫过的前缀重扫一遍 const resumeCursor = subtreePendingGroupCursors.get(group.id) ?? 0; - subtreePendingGroupCursors.delete(group.id); const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value); let cursor = resumeCursor; let mergedNewKeys = 0; let iterationsLeft = SUBTREE_FILL_MAX_SCAN_ITERATIONS; try { - while (requestId === searchRequestId && !isFetchingAll.value && iterationsLeft > 0) { + while (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId && expandedGroupIds.value.has(group.id) && !isFetchingAll.value && iterationsLeft > 0) { if (flatKeys.value.length >= redisInfiniteScrollMaxKeys.value) break; if (mergedNewKeys >= SUBTREE_FILL_MAX_NEW_KEYS) break; const iterations = Math.min(SUBTREE_SCAN_ITERATIONS_PER_CALL, iterationsLeft); const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, iterations, true); iterationsLeft -= iterations; - if (requestId !== searchRequestId) return; + if (requestId !== searchRequestId || subtreeFillOperations.get(group.id) !== operationId) return; const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws); mergeScannedKeys(newKeys); mergedNewKeys += newKeys.length; cursor = result.cursor; if (cursor === 0) { + subtreePendingGroupCursors.delete(group.id); subtreeFilledGroupIds.add(group.id); return; } + // Keep the last applied cursor available while the next request is in + // flight. A KeepAlive pause can invalidate that request, but must not + // force the retained subtree to restart from cursor zero. + subtreePendingGroupCursors.set(group.id, cursor); } // 子树未扫尽(预算用尽或触到全局键数上限):保留游标等待续扫 - subtreePendingGroupCursors.set(group.id, cursor); + if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) subtreePendingGroupCursors.set(group.id, cursor); } catch (error) { // 失败不阻塞浏览;已并入的进度保留在游标里,下次展开/续扫从断点重试 - subtreePendingGroupCursors.set(group.id, cursor); - toast(errorMessage(error), 5000); + if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) { + subtreePendingGroupCursors.set(group.id, cursor); + toast(errorMessage(error), 5000); + } } finally { - subtreeFillingGroupIds.delete(group.id); + if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) { + // Keep confirmed progress during the pass, then let other pending groups + // take the next continuation. Completed or invalidated fills stay removed. + const pendingCursor = subtreePendingGroupCursors.get(group.id); + if (pendingCursor !== undefined) { + subtreePendingGroupCursors.delete(group.id); + subtreePendingGroupCursors.set(group.id, pendingCursor); + } + subtreeFillOperations.delete(group.id); + } } } @@ -1382,12 +1401,14 @@ async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = search function resumePendingGroupSubtrees(requestId = searchRequestId) { if (!shouldFillGroupSubtree()) return; for (const groupId of subtreePendingGroupCursors.keys()) { + if (subtreeFillOperations.has(groupId)) continue; const group = treeIndex?.groupById.get(groupId); if (!group) { // 分组已不存在(键被删除或树被重建),游标作废 subtreePendingGroupCursors.delete(groupId); continue; } + if (!expandedGroupIds.value.has(groupId)) continue; void fillGroupSubtree(group, requestId); return; } @@ -2259,6 +2280,9 @@ function onSearchInput() { fetchAllLoadedCount.value = 0; selectedKeyRaw.value = null; resetCheckedKeys(); + // The rendered rows may stay visible while editing, but their cursor + // belongs to the last submitted pattern and must not be continued. + hasMore.value = false; return; } if (searchTimer) clearTimeout(searchTimer); @@ -2528,11 +2552,16 @@ function pauseRedisBrowserBackgroundWork() { // 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。 if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame); redisInfiniteScrollFrame = 0; - invalidateScanRequests(); + // KeepAlive retains this operation's rows and cursor, so pausing invalidates + // ownership without granting the same operation another automatic budget. + invalidateScanRequests(false); isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; loading.value = false; + // The edited value/all query has not run yet; its old cursor cannot be + // retained as progress for the new query when we cancel the debounce. + if (searchPending.value) reloadKeysOnActivation = true; searchPending.value = false; if (searchTimer) clearTimeout(searchTimer); searchTimer = null; @@ -2640,16 +2669,18 @@ onMounted(async () => { onActivated(async () => { resumeRedisBrowserBackgroundWork(); void autofocusSearchOnce(); - const shouldReload = reloadKeysOnActivation; - reloadKeysOnActivation = false; // Ensure the connection is still alive after reactivation (e.g. tab switch). - // If keys failed to load previously (empty list), retry loading. + // Retry an empty failed/interrupted load, but retain successful sparse pages. try { await connectionStore.ensureConnected(props.connectionId); } catch (e) { console.warn("[DBX] ensureConnected failed for", props.connectionId, e); } - if ((shouldReload || flatKeys.value.length === 0) && !loading.value) { + // loadKeys resets the cursor before requesting its first page. A nonzero + // retained cursor therefore distinguishes an applied empty page from a + // failed/interrupted reload even if hasMore still reflects the previous load. + const hasRetainedScanCursor = hasMore.value && scanCursor.value !== 0; + if ((reloadKeysOnActivation || (flatKeys.value.length === 0 && !hasRetainedScanCursor)) && !loading.value) { void loadKeys(); } }); @@ -2726,7 +2757,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); -