From 3edafe58ddd18ed730c24552afe82b6c2342859d Mon Sep 17 00:00:00 2001 From: Rendegou <2739704861@qq.com> Date: Sun, 11 Oct 2026 14:21:35 +0800 Subject: [PATCH] fix(redis): complete collection searches and rank exact hash fields --- .../RedisValueViewer.collectionSearch.spec.ts | 394 ++++++++++++++++- .../src/components/redis/RedisValueViewer.vue | 212 +++++---- apps/desktop/src/i18n/locales/en.ts | 14 + apps/desktop/src/i18n/locales/es.ts | 14 + apps/desktop/src/i18n/locales/it.ts | 14 + apps/desktop/src/i18n/locales/ja.ts | 14 + apps/desktop/src/i18n/locales/ko.ts | 14 + apps/desktop/src/i18n/locales/pt-BR.ts | 14 + apps/desktop/src/i18n/locales/ru.ts | 14 + apps/desktop/src/i18n/locales/zh-CN.ts | 14 + apps/desktop/src/i18n/locales/zh-TW.ts | 14 + .../redisCollectionSearchController.spec.ts | 316 +++++++++++++ .../redis/redisCollectionSearchController.ts | 201 +++++++++ crates/dbx-driver-redis/src/lib.rs | 417 ++++++++++++++++-- docs/screenshots/issue-11491-search-dark.png | Bin 0 -> 22518 bytes docs/screenshots/issue-11491-search-light.png | Bin 0 -> 21439 bytes 16 files changed, 1547 insertions(+), 119 deletions(-) create mode 100644 apps/desktop/src/lib/redis/__tests__/redisCollectionSearchController.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisCollectionSearchController.ts create mode 100644 docs/screenshots/issue-11491-search-dark.png create mode 100644 docs/screenshots/issue-11491-search-light.png diff --git a/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts index 77adb90866..8d5e924c75 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts +++ b/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { createApp, defineComponent, h, 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"; @@ -67,10 +67,12 @@ afterEach(() => { host.remove(); } vi.unstubAllGlobals(); + vi.useRealTimers(); }); beforeEach(() => { vi.clearAllMocks(); + mocks.redisLoadMore.mockReset(); vi.stubGlobal("localStorage", createLocalStorage()); }); @@ -135,46 +137,402 @@ const testI18nMessages = { loadedMembers: "{loaded} / {total} members loaded", loadedItems: "{loaded} / {total} items loaded", loadMoreKeys: "Load more", + fields: "{count} fields", + ttlSecond: "{count}s", + collectionSearching: "Searching", + collectionSearchSubmit: "Search", + collectionSearchSubmitHint: "Click Search or press Enter", + collectionSearchStop: "Stop search", + collectionSearchCancel: "Cancel search", + collectionSearchStopped: "Search stopped; results and progress kept", + collectionSearchPartial: "Partial results; search not complete", + collectionSearchComplete: "Search complete", + collectionSearchEmpty: "No matches found", + collectionSearchPaused: "Search paused; not complete", + collectionSearchStalled: "Search did not advance", + collectionSearchFailed: "Search failed: {error}", + collectionSearchContinue: "Continue search", + collectionSearchRetry: "Retry search", + stopFetchAll: "Stop", }, }, }; -function mountViewer() { +function mountViewer(keepAlive = false, keyRaw = ref("key")) { + const visible = ref(true); const host = document.createElement("div"); document.body.append(host); const app = createApp( defineComponent({ setup() { - return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" }); + const viewer = () => (visible.value ? h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: keyRaw.value, keyRaw: keyRaw.value }) : null); + return () => (keepAlive ? h(KeepAlive, null, { default: viewer }) : viewer()); }, }), ); app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false })); app.mount(host); mountedApps.push({ unmount: () => app.unmount(), host }); + return visible; } function searchInput(): HTMLInputElement | null { - return document.querySelector("input[placeholder='Search members'], input[placeholder='Search items']"); + return document.querySelector("input[placeholder='Search fields and values'], input[placeholder='Search members'], input[placeholder='Search items']"); } function loadMoreButton(): HTMLButtonElement { const buttons = [...document.querySelectorAll("button")]; - return buttons.find((button) => button.textContent?.includes("Load more"))!; + return buttons.find((button) => button.textContent?.includes("Load more") || button.textContent?.includes("Continue search"))!; } -async function typeSearch(query: string) { +async function typeDraft(query: string) { const input = searchInput()!; input.value = query; input.dispatchEvent(new Event("input", { bubbles: true })); - // The Input wrapper syncs its v-model through a passive watcher, so the query only - // reaches the viewer on the next tick — press Enter after it lands, like real typing. + // Let the Input wrapper's passive v-model watcher settle before submission. await nextTick(); +} + +async function typeSearch(query: string) { + await typeDraft(query); + const input = searchInput()!; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await settle(); } describe("Redis collection member search", () => { + it("waits for the Search button and does not scan while typing", async () => { + vi.useFakeTimers(); + mocks.redisGetValue.mockResolvedValue(setValue()); + mocks.redisLoadMore.mockResolvedValue({ kind: "set", items: [{ member: blob("needle") }] }); + mountViewer(); + await settle(); + expect(document.querySelector("[data-redis-search-submit]")!.disabled).toBe(true); + await typeDraft("needle"); + await vi.advanceTimersByTimeAsync(1000); + expect(mocks.redisLoadMore).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("alpha"); + document.querySelector("[data-redis-search-submit]")!.click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "set", 0, 200, "needle", undefined); + expect(document.body.textContent).toContain("needle"); + }); + + it("cancels an in-flight search, restores browsing, and ignores the old response", async () => { + const browsing = { ...setValue(), data: { kind: "set", items: [{ member: blob("alpha") }], total: 100, scan_cursor: 10 } }; + mocks.redisGetValue.mockResolvedValue(browsing); + let finish!: (result: unknown) => void; + mocks.redisLoadMore + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValueOnce({ kind: "set", items: [{ member: blob("beta") }] }); + mountViewer(); + await settle(); + await typeSearch("old"); + document.querySelector("[data-redis-search-cancel]")!.click(); + await settle(); + expect(searchInput()?.value).toBe(""); + expect(document.body.textContent).toContain("alpha"); + expect(document.querySelector("[data-redis-search-stop]")).toBeNull(); + loadMoreButton().click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + finish({ kind: "set", items: [{ member: blob("obsolete") }], scan_cursor: 99 }); + for (let i = 0; i < 4; i++) await settle(); + expect(mocks.redisLoadMore.mock.calls[1][4]).toBe(10); + expect(mocks.redisLoadMore.mock.calls[1][6]).toBeUndefined(); + expect(document.body.textContent).toContain("alpha"); + expect(document.body.textContent).toContain("beta"); + expect(document.body.textContent).not.toContain("obsolete"); + }); + + it("discards a submitted search on editing, and Escape cancels the new draft", async () => { + mocks.redisGetValue.mockResolvedValue(setValue()); + mocks.redisLoadMore.mockResolvedValue({ kind: "set", items: [{ member: blob("found") }] }); + mountViewer(); + await settle(); + await typeSearch("found"); + await typeDraft("new keyword"); + await settle(); + expect(document.body.textContent).toContain("alpha"); + expect(document.body.textContent).not.toContain("found"); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + searchInput()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(); + expect(searchInput()?.value).toBe(""); + expect(document.querySelector("[data-redis-search-cancel]")).toBeNull(); + }); + + it("does not submit Enter while an IME composition is active", async () => { + mocks.redisGetValue.mockResolvedValue(setValue()); + mountViewer(); + await settle(); + await typeDraft("keyword"); + searchInput()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true })); + await settle(); + expect(mocks.redisLoadMore).not.toHaveBeenCalled(); + }); + + it("preserves an unsubmitted draft during a ZSet sort reload", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: zsetValue().data.items, scan_cursor: 2 }); + mountViewer(); + await settle(); + await typeDraft("draft"); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Score"))!.click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + expect(mocks.redisLoadMore.mock.calls[0][6]).toBeUndefined(); + expect(searchInput()?.value).toBe("draft"); + }); + + it.each(["cancel", "edit"])("preserves a queued %s before the ZSet loading view renders", async (intent) => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockImplementation((_connection, _db, _key, _kind, _cursor, _count, query) => Promise.resolve({ kind: "zset", items: zsetValue().data.items, scan_cursor: query ? undefined : 2 })); + mountViewer(); + await settle(); + await typeSearch("A"); + mocks.redisLoadMore.mockClear(); + + let finish!: (value: unknown) => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + mocks.redisGetValue.mockReturnValueOnce(pending); + const input = searchInput()!; + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Score"))!.click(); + // Both handlers run before Vue replaces the toolbar with the loading view. + if (intent === "cancel") document.querySelector("[data-redis-search-cancel]")!.click(); + else await typeDraft("B"); + await settle(); + // Enter during a reload must obey the same disabled state as the button. + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + finish(zsetValue()); + for (let i = 0; i < 4; i++) await settle(); + + expect(searchInput()?.value).toBe(intent === "cancel" ? "" : "B"); + expect(mocks.redisLoadMore.mock.calls.every((call) => call[6] == null)).toBe(true); + expect(mocks.redisLoadMore).not.toHaveBeenCalled(); + mocks.redisLoadMore.mockClear(); + loadMoreButton().click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 2, 200, undefined, "asc"); + }); + + it("updates duplicate Hash values and TTLs without replacing an edit draft or explicit sorting", async () => { + let finish!: (result: unknown) => void; + mocks.redisGetValue.mockResolvedValue({ key_display: "hash", key_raw: "hash", ttl: -1, redis_type: "hash", data: { kind: "hash", items: [], total: 25000, scan_cursor: 10 } }); + mocks.redisLoadMore.mockResolvedValueOnce({ kind: "hash", items: [{ field: blob("b-field"), value: blob("old"), field_ttl: 100 }], scan_cursor: 10 }).mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + mountViewer(); + await settle(); + document.querySelector("[role='columnheader'] button")!.click(); + await settle(); + await typeSearch("field"); + document.querySelector("[data-redis-value-row]")!.click(); + await settle(); + document.querySelector("[data-redis-member-utf8-viewer]")!.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + await settle(); + const editor = document.querySelector("[data-redis-member-utf8-editor]")!; + editor.value = "retained draft"; + editor.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + finish({ + kind: "hash", + items: [ + { field: blob("b-field"), value: blob("new"), field_ttl: 50 }, + { field: blob("a-field"), value: blob("other"), field_ttl: 80 }, + ], + }); + await settle(); + expect(editor.value).toBe("retained draft"); + expect(document.querySelector("[role='columnheader']")?.getAttribute("aria-sort")).toBe("ascending"); + const rows = [...document.querySelectorAll("[data-redis-value-row]")]; + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain("a-field"); + expect(rows[1].textContent).toContain("new"); + expect(rows[1].textContent).toContain("50s"); + }); + it("retries an initial load that settled while the viewer was inactive", async () => { + let finish!: (value: unknown) => void; + mocks.redisGetValue + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValueOnce(setValue()); + const visible = mountViewer(true); + await settle(); + visible.value = false; + await settle(); + finish(setValue()); + await settle(); + visible.value = true; + await settle(); + expect(mocks.redisGetValue).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("alpha"); + expect(searchInput()).not.toBeNull(); + }); + it("does not let a stale search overwrite a refreshed collection", async () => { + mocks.redisGetValue.mockResolvedValueOnce(setValue()).mockResolvedValueOnce({ ...setValue(), data: { kind: "set", items: [{ member: blob("fresh") }], total: 1 } }); + let finish!: (page: unknown) => void; + mocks.redisLoadMore.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + mountViewer(); + await settle(); + await typeSearch("old"); + document.querySelector("[data-redis-value-refresh]")!.click(); + await settle(); + finish({ kind: "set", items: [{ member: blob("obsolete-result") }], scan_cursor: 10 }); + await settle(); + expect(document.body.textContent).toContain("fresh"); + expect(document.body.textContent).not.toContain("obsolete-result"); + expect(searchInput()?.value).toBe(""); + }); + it("uses the new key after scope invalidation while the old request is still in flight", async () => { + mocks.redisGetValue.mockResolvedValue(setValue()); + let finish!: (page: unknown) => void; + mocks.redisLoadMore + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValueOnce({ kind: "set", items: [{ member: blob("new-result") }] }); + const key = ref("key"); + mountViewer(false, key); + await settle(); + await typeSearch("old"); + key.value = "new-key"; + await settle(); + await typeSearch("new"); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + finish({ kind: "set", items: [{ member: blob("obsolete-result") }] }); + for (let i = 0; i < 4; i++) await settle(); + expect(mocks.redisLoadMore.mock.calls[1][2]).toBe("new-key"); + expect(document.body.textContent).toContain("new-result"); + expect(document.body.textContent).not.toContain("obsolete-result"); + }); + it("allows ordinary paging to resume after deactivation without consuming it twice", async () => { + mocks.redisGetValue.mockResolvedValue({ ...setValue(), data: { ...setValue().data, total: 100, scan_cursor: 10 } }); + let finish!: (page: unknown) => void; + mocks.redisLoadMore.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const visible = mountViewer(true); + await settle(); + loadMoreButton().click(); + await settle(); + visible.value = false; + await settle(); + finish({ kind: "set", items: [{ member: blob("resumed") }], scan_cursor: undefined }); + await settle(); + visible.value = true; + await settle(); + loadMoreButton().click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("resumed"); + }); + it("rolls back a descending reload canceled by deactivation and keeps ascending pagination", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + const visible = mountViewer(true); + await settle(); + let finish!: (value: unknown) => void; + mocks.redisGetValue.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Score"))!.click(); + await settle(); + visible.value = false; + await settle(); + finish(zsetValue()); + await settle(); + visible.value = true; + await settle(); + mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined }); + loadMoreButton().click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 2, 200, undefined, "asc"); + }); + it("automatically reaches a hash field after consecutive empty scan slices", async () => { + mocks.redisGetValue.mockResolvedValue({ key_display: "hash", key_raw: "hash", ttl: -1, redis_type: "hash", data: { kind: "hash", items: [], total: 25271, scan_cursor: 10 } }); + mocks.redisLoadMore + .mockResolvedValueOnce({ kind: "hash", items: [], scan_cursor: 20 }) + .mockResolvedValueOnce({ kind: "hash", items: [], scan_cursor: 30 }) + .mockResolvedValueOnce({ kind: "hash", items: [{ field: blob("EC00100051EB02DD"), value: blob("exists") }], scan_cursor: 40 }) + .mockResolvedValueOnce({ kind: "hash", items: [] }); + mountViewer(); + await settle(); + await typeSearch("EC00100051EB02DD"); + for (let i = 0; i < 4; i++) await settle(); + expect(mocks.redisLoadMore.mock.calls.map((call) => call[4])).toEqual([0, 20, 30, 40]); + expect(document.body.textContent).toContain("1 fields"); + expect(document.body.textContent).toContain("Search complete"); + }); + + it("does not enqueue intermediate keywords behind a delayed search", async () => { + let finish!: (result: unknown) => void; + mocks.redisGetValue.mockResolvedValue(setValue()); + mocks.redisLoadMore + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValueOnce({ kind: "set", items: [{ member: blob("C") }], scan_cursor: undefined }); + mountViewer(); + await settle(); + await typeSearch("A"); + await typeSearch("B"); + await typeSearch("C"); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + finish({ kind: "set", items: [{ member: blob("A") }], scan_cursor: 10 }); + for (let i = 0; i < 4; i++) await settle(); + expect(mocks.redisLoadMore.mock.calls.map((call) => call[6])).toEqual(["A", "C"]); + expect(document.body.textContent).toContain("C"); + expect(document.body.textContent).toContain("Search complete"); + }); + + it("shows stopping as incomplete, and can continue from an unstarted first cursor", async () => { + let finish!: (result: unknown) => void; + mocks.redisGetValue.mockResolvedValue(setValue()); + mocks.redisLoadMore + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValueOnce({ kind: "set", items: [], scan_cursor: undefined }); + mountViewer(); + await settle(); + await typeSearch("missing"); + document.querySelector("[data-redis-search-stop]")!.click(); + await settle(); + expect(document.body.textContent).toContain("Search stopped; results and progress kept"); + expect(document.body.textContent).not.toContain("No matches found"); + document.querySelector("[data-redis-search-continue]")!.click(); + await settle(); + expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1); + finish({ kind: "set", items: [], scan_cursor: 50 }); + for (let i = 0; i < 4; i++) await settle(); + expect(mocks.redisLoadMore.mock.calls[1][4]).toBe(50); + expect(document.body.textContent).toContain("No matches found"); + }); it("offers a member search box for zset keys", async () => { mocks.redisGetValue.mockResolvedValue(zsetValue()); mountViewer(); @@ -217,23 +575,23 @@ describe("Redis collection member search", () => { expect(document.body.textContent).not.toContain("2314099896:11842851748888"); }); - it("carries the active query into load-more paging", async () => { + it("carries the active query into automatic continuation", async () => { mocks.redisGetValue.mockResolvedValue(zsetValue()); - mocks.redisLoadMore.mockResolvedValue({ - kind: "zset", - items: [{ score: "2", member: blob("2314101855:22903060973580") }], - scan_cursor: 200, - }); + mocks.redisLoadMore + .mockResolvedValueOnce({ + kind: "zset", + items: [{ score: "2", member: blob("2314101855:22903060973580") }], + scan_cursor: 200, + }) + .mockResolvedValueOnce({ kind: "zset", items: [], scan_cursor: undefined }); mountViewer(); await settle(); await typeSearch("22903060"); - mocks.redisLoadMore.mockClear(); - mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined }); - loadMoreButton().click(); - await settle(); + for (let i = 0; i < 4; i++) await settle(); expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 200, 200, "22903060", "asc"); + expect(document.body.textContent).toContain("Search complete"); }); it("keeps the query when the zset sort direction is toggled", async () => { diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 3637aa006e..0533470a79 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -5,7 +5,7 @@ import type { CalendarDateTime } from "@internationalized/date"; import { useI18n } from "vue-i18n"; import { onClickOutside } from "@vueuse/core"; import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller"; -import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, X, FileArchive, Download } from "@lucide/vue"; +import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, Pause, X, FileArchive, Download } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -40,7 +40,6 @@ import { parseRedisJsonDetail, REDIS_VALUE_FORMAT_DISPLAY_ORDER, redisBlobText, - redisCollectionPageItems, redisJsonValueText, normalizeRedisJsonDraft, redisClipboardSafeText, @@ -73,6 +72,7 @@ import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, va import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; import { formatBytes } from "@/lib/database/serverMetrics"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; +import { emptyRedisCollectionSearchState, RedisCollectionSearchController, type RedisCollectionScope } from "@/lib/redis/redisCollectionSearchController"; const { t, locale } = useI18n(); const { toast } = useToast(); @@ -412,6 +412,9 @@ const valueSearchHasNavigated = ref(false); const collectionSearchQuery = ref(""); const activeCollectionSearchQuery = ref(""); const searchLoading = ref(false); +const collectionSearchState = shallowRef(emptyRedisCollectionSearchState()); +let collectionBrowseSnapshot: { items: RedisCollectionItem[]; cursor: number | undefined } | undefined; +let collectionSearchIntentRevision = 0; const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null); type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean }; const stringJsonEditorRef = ref(null); @@ -422,6 +425,7 @@ const memberTextareaRef = ref(null); const valueViewerSearchActive = ref(false); function toggleHashSort(column: "field" | "value") { + stopCollectionSearch(); if (hashSortBy.value === column && hashSortDir.value === "desc") { hashSortBy.value = null; } else if (hashSortBy.value === column) { @@ -437,7 +441,7 @@ async function toggleZsetSort() { const previousDirection = zsetSortDir.value; zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc"; try { - await reloadPreservingCollectionSearch({ notifyParent: false }); + if (!(await reloadPreservingCollectionSearch({ notifyParent: false }))) zsetSortDir.value = previousDirection; } catch (error) { zsetSortDir.value = previousDirection; toast(errorMessage(error), 3000); @@ -863,8 +867,6 @@ const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolea const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value); const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value); -let collectionSearchTimer: ReturnType | null = null; -let collectionSearchRequestId = 0; let hashResizeStartX = 0; let hashResizeStartWidth = 0; let zsetResizeStartX = 0; @@ -1194,53 +1196,103 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb /** True for the collection kinds whose toolbar offers a server-filtered member search. */ const collectionSearchSupported = computed(() => redisKind.value === "hash" || redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "zset"); -function onCollectionSearchInput() { - if (collectionSearchTimer) clearTimeout(collectionSearchTimer); - collectionSearchTimer = setTimeout(() => void onCollectionSearch(), 400); +const collectionSearchController = new RedisCollectionSearchController({ + fetchPage: ({ scope, cursor, query }) => api.redisLoadMore(scope.connectionId, scope.db, scope.keyRaw, scope.kind, cursor, 200, query || undefined, scope.sortDirection), + changed: (state) => { + collectionSearchState.value = state; + activeCollectionSearchQuery.value = state.query; + searchLoading.value = Boolean(state.query) && state.status === "searching"; + loadingMore.value = !state.query && state.status === "searching"; + if (state.status !== "idle") { + collectionItems.value = state.items; + scanCursor.value = state.cursor ?? undefined; + } + }, +}); + +function collectionScope(): RedisCollectionScope | undefined { + const kind = redisKind.value; + if (kind !== "hash" && kind !== "list" && kind !== "set" && kind !== "zset") return; + return { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw, kind, sortDirection: kind === "zset" ? zsetSortDir.value : undefined }; +} + +const collectionSearchStatusText = computed(() => { + const state = collectionSearchState.value; + if (state.status === "failed") return t("redis.collectionSearchFailed", { error: errorMessage(state.error) }); + if (state.pauseReason === "stalled") return t("redis.collectionSearchStalled"); + if (state.status === "complete") return t(state.items.length ? "redis.collectionSearchComplete" : "redis.collectionSearchEmpty"); + if (state.status === "partial") return t("redis.collectionSearchPartial"); + if (state.pauseReason === "stopped") return t("redis.collectionSearchStopped"); + if (state.status === "paused") return t("redis.collectionSearchPaused"); + return t("redis.collectionSearching"); +}); + +const collectionSearchPlaceholder = computed(() => t(redisKind.value === "hash" ? "redis.searchFields" : redisKind.value === "list" ? "redis.searchItems" : "redis.searchMembers")); +const canContinueCollectionSearch = computed(() => Boolean(activeCollectionSearchQuery.value) && collectionSearchState.value.cursor != null && !searchLoading.value); + +function resetCollectionSearchResults() { + collectionSearchController.cancelSearch(); + if (collectionBrowseSnapshot) { + collectionItems.value = collectionBrowseSnapshot.items; + scanCursor.value = collectionBrowseSnapshot.cursor; + collectionBrowseSnapshot = undefined; + } + if (!hasRetainedMemberDraft.value) clearSelectedMember(); +} + +function onCollectionSearchInput(event: Event) { + // Editing is a draft, not a submitted search. Invalidate any old request now. + // Capture before the Input wrapper's passive v-model watcher or a reload unmount. + collectionSearchQuery.value = (event.target as HTMLInputElement).value; + collectionSearchIntentRevision++; + resetCollectionSearchResults(); +} + +function cancelCollectionSearch() { + collectionSearchIntentRevision++; + collectionSearchQuery.value = ""; + resetCollectionSearchResults(); +} + +function stopCollectionSearch() { + collectionSearchController.stop(); } function onCollectionSearchKeydown(event: KeyboardEvent) { + if (event.isComposing) return; if (event.key === "Enter") { - if (collectionSearchTimer) clearTimeout(collectionSearchTimer); - collectionSearchTimer = null; - void onCollectionSearch(); + event.preventDefault(); + if (!loading.value && !searchLoading.value) void (canContinueCollectionSearch.value ? collectionSearchController.start() : onCollectionSearch()); return; } if (event.key === "Escape") { - if (collectionSearchTimer) clearTimeout(collectionSearchTimer); - collectionSearchTimer = null; - collectionSearchQuery.value = ""; - void onCollectionSearch(); + event.preventDefault(); + cancelCollectionSearch(); } } async function onCollectionSearch() { + const scope = collectionScope(); const query = collectionSearchQuery.value.trim(); - const keyType = redisKind.value; - if (!collectionSearchSupported.value) return; - const requestId = ++collectionSearchRequestId; - searchLoading.value = true; - try { - const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined; - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, 0, 200, query || undefined, sortDirection); - if (requestId !== collectionSearchRequestId || result.kind !== keyType) return; - activeCollectionSearchQuery.value = query; - collectionItems.value = result.items; - scanCursor.value = result.scan_cursor ?? undefined; - if (!hasRetainedMemberDraft.value) clearSelectedMember(); - } finally { - if (requestId === collectionSearchRequestId) searchLoading.value = false; - } + if (!scope || loading.value || !query) return; + collectionSearchIntentRevision++; + if (!activeCollectionSearchQuery.value) collectionBrowseSnapshot = { items: collectionItems.value, cursor: scanCursor.value }; + collectionSearchController.prepare(scope, query); + if (!hasRetainedMemberDraft.value) clearSelectedMember(); + await collectionSearchController.start(); } /** `load()` clears the search box, so anything that reloads mid-search has to re-apply the query. */ async function reloadPreservingCollectionSearch(options: { notifyParent?: boolean } = {}) { - const query = activeCollectionSearchQuery.value || collectionSearchQuery.value.trim(); - await load({ ...options, selectDefaultMember: false }); + const query = activeCollectionSearchQuery.value; + const draft = collectionSearchQuery.value; + const revision = collectionSearchIntentRevision; + if (!(await load({ ...options, selectDefaultMember: false, shouldApply: () => revision === collectionSearchIntentRevision }))) return false; + collectionSearchQuery.value = draft; if (query) { - collectionSearchQuery.value = query; await onCollectionSearch(); } + return true; } function readRedisJsonWordWrap(): boolean { @@ -1602,11 +1654,15 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre const notifyParent = options.notifyParent ?? true; const shouldSelectDefaultMember = options.selectDefaultMember ?? true; const requestId = ++loadRequestId; + if (!background) stopCollectionSearch(); + const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw }; if (!background) loading.value = true; try { - let loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); + let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw); + if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false; if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") { - const sortedPage = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "zset", 0, 200, undefined, "desc"); + const sortedPage = await collectionSearchController.readPage({ ...loadScope, kind: "zset", sortDirection: "desc" }, 0, "", () => requestId === loadRequestId && (!options.shouldApply || options.shouldApply())); + if (!sortedPage) return false; if (sortedPage.kind === "zset") { loadedValue = { ...loadedValue, @@ -1618,7 +1674,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre }; } } - if (requestId !== loadRequestId || (options.shouldApply && !options.shouldApply())) return false; + if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false; // Redis reports a key that expired between refreshes as a `none` value. // Tell the browser to remove it instead of rendering a stale detail shell. @@ -1649,9 +1705,8 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre return false; } - if (collectionSearchTimer) clearTimeout(collectionSearchTimer); - collectionSearchTimer = null; - collectionSearchRequestId++; + collectionBrowseSnapshot = undefined; + collectionSearchController.reset(); collectionSearchQuery.value = ""; activeCollectionSearchQuery.value = ""; searchLoading.value = false; @@ -1721,20 +1776,10 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre async function loadMore() { if (!data.value || !hasMore.value || loadingMore.value || searchLoading.value) return; if (!collectionSearchSupported.value) return; - const keyType = redisKind.value; - const filter = activeCollectionSearchQuery.value || undefined; - const requestId = collectionSearchRequestId; - loadingMore.value = true; - try { - const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined; - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, filter, sortDirection); - if (requestId !== collectionSearchRequestId) return; - const newItems = redisCollectionPageItems(result); - collectionItems.value = [...collectionItems.value, ...newItems]; - scanCursor.value = result.scan_cursor ?? undefined; - } finally { - loadingMore.value = false; - } + const scope = collectionScope(); + if (!scope) return; + if (activeCollectionSearchQuery.value) await collectionSearchController.start(); + else await collectionSearchController.browse(scope, collectionItems.value, scanCursor.value!); } async function saveString() { @@ -2758,6 +2803,10 @@ watch(contentSearchText, () => { watch( () => [props.connectionId, props.db, props.keyRaw], () => { + loadRequestId++; + collectionBrowseSnapshot = undefined; + collectionSearchController.reset(); + collectionSearchQuery.value = ""; resetValueSearch(); valueViewerSearchActive.value = false; resetStreamEntries(); @@ -2806,20 +2855,24 @@ onMounted(() => { }); onActivated(() => { redisValueViewerIsActive = true; + collectionSearchController.setActive(true); + if (!data.value && !loading.value) void load(); startRefreshTimers(); }); onDeactivated(() => { redisValueViewerIsActive = false; + collectionSearchController.setActive(false); stopRefreshTimers(); }); onBeforeUnmount(() => { + loadRequestId++; + collectionSearchController.dispose(); window.removeEventListener("pointerdown", handleValueViewerPointerDown, true); document.removeEventListener("visibilitychange", handleDocumentVisibilityChange); redisValueViewerIsActive = false; stopRefreshTimers(); stopResizeHashColumns(); stopResizeZsetColumns(); - if (collectionSearchTimer) clearTimeout(collectionSearchTimer); }); defineExpose({ focusSearch }); @@ -2923,6 +2976,33 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving +
+
+ + +
+ + + + +
+
+
+
@@ -3100,10 +3180,6 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
{{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }} -
- - -
@@ -3134,7 +3210,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving