diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 42f4acad8c..0b1dcf34bf 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -24,7 +24,7 @@ import { Badge } from "@/components/ui/badge"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import RedisValueViewer from "./RedisValueViewer.vue"; import * as api from "@/lib/api"; -import type { RedisKeyInfo } from "@/lib/api"; +import type { RedisKeyInfo, RedisScanResult } from "@/lib/api"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { @@ -71,6 +71,7 @@ const commandText = ref(""); const commandResult = ref(null); const commandError = ref(""); const commandRunning = ref(false); +let searchRequestId = 0; const keyGridStyle = { gridTemplateColumns: "minmax(12rem, 0.35fr) 80px 1fr 60px 60px", @@ -84,6 +85,9 @@ const isSearchMode = computed(() => const searchPlaceholder = computed(() => searchMode.value === "key" ? t("redis.pattern") : t("redis.valueSearchPlaceholder"), ); +const loadingEmptyText = computed(() => + searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"), +); const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null); const dangerDetails = computed(() => { if (!pendingDanger.value) return ""; @@ -133,12 +137,14 @@ function rebuildTree(expandAll = false) { } } -async function scanNextPage() { +async function fetchScanPage(): Promise { const pageSize = settingsStore.editorSettings.redisScanPageSize; - const result = - searchMode.value === "value" - ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) - : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize); + return searchMode.value === "value" + ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) + : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize); +} + +function appendScanResult(result: RedisScanResult) { const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw)); flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))]; scanCursor.value = result.cursor; @@ -150,7 +156,22 @@ async function scanNextPage() { }); } +async function scanNextPage(requestId = searchRequestId): Promise { + const result = await fetchScanPage(); + if (requestId !== searchRequestId) return false; + appendScanResult(result); + return true; +} + +async function streamValueSearch(requestId: number) { + while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) { + const applied = await scanNextPage(requestId); + if (!applied) return; + } +} + async function loadKeys() { + const requestId = ++searchRequestId; loading.value = true; flatKeys.value = []; treeKeys.value = []; @@ -163,17 +184,23 @@ async function loadKeys() { hasMore.value = false; return; } - await scanNextPage(); + const applied = await scanNextPage(requestId); + if (applied && searchMode.value === "value") { + await streamValueSearch(requestId); + } } finally { - loading.value = false; + if (requestId === searchRequestId) { + loading.value = false; + } } } async function loadMore() { if (!hasMore.value || loadingMore.value) return; + const requestId = searchRequestId; loadingMore.value = true; try { - await scanNextPage(); + await scanNextPage(requestId); } finally { loadingMore.value = false; } @@ -387,6 +414,7 @@ function onSearchKeydown(event: KeyboardEvent) { } onUnmounted(() => { + searchRequestId++; if (searchTimer) clearTimeout(searchTimer); }); @@ -442,7 +470,7 @@ defineExpose({ focusSearch }); {{ - loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length }) + loading && flatKeys.length === 0 ? loadingEmptyText : t("redis.keys", { count: flatKeys.length }) }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 521357c99d..e8dc6c2a19 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -847,6 +847,7 @@ export default { searchByValue: "Value", keys: "{count} keys", loadingKeys: "Loading keys...", + searchingValues: "Searching values...", loadMoreKeys: "Load more keys", items: "{count} items", fields: "{count} fields", @@ -866,6 +867,8 @@ export default { flushDb: "Clear current DB", flushDbDetails: "Redis db{db}", commandPlaceholder: "Redis command, e.g. GET user:1", + commandPrefix: "CMD", + commandHint: "Enter a Redis command, e.g. SMEMBERS feature:flags", executeCommand: "Execute command", commandEmpty: "Enter a Redis command", commandBlocked: "This Redis command is blocked for safety", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 714974f7a7..12a3c57877 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -752,6 +752,7 @@ export default { searchByValue: "Valor", keys: "{count} claves", loadingKeys: "Cargando claves...", + searchingValues: "Buscando por valor...", loadMoreKeys: "Cargar más claves", items: "{count} elementos", fields: "{count} campos", @@ -771,6 +772,8 @@ export default { flushDb: "Limpiar DB actual", flushDbDetails: "Redis db{db}", commandPlaceholder: "Comando Redis, p. ej. GET user:1", + commandPrefix: "CMD", + commandHint: "Ingresa un comando Redis, p. ej. SMEMBERS feature:flags", executeCommand: "Ejecutar comando", commandEmpty: "Ingresa un comando Redis", commandBlocked: "Este comando Redis está bloqueado por seguridad", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 94c89619bf..250969631d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -827,6 +827,7 @@ export default { searchByValue: "值", keys: "{count} 个 key", loadingKeys: "正在加载 key...", + searchingValues: "正在按值搜索...", loadMoreKeys: "加载更多", items: "{count} 个元素", fields: "{count} 个字段", @@ -845,6 +846,8 @@ export default { flushDb: "清空当前 DB", flushDbDetails: "Redis db{db}", commandPlaceholder: "Redis 命令,如 GET user:1", + commandPrefix: "命令", + commandHint: "输入 Redis 命令,如 SMEMBERS feature:flags", executeCommand: "执行命令", commandEmpty: "请输入 Redis 命令", commandBlocked: "出于安全考虑,此 Redis 命令已被阻止", diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index d4a3b5e9a7..75522da647 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -361,22 +361,23 @@ pub async fn scan_values_page( query: &str, count: usize, ) -> Result { + let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0); + if query.trim().is_empty() { + return Ok(RedisScanResult { cursor, keys: Vec::new(), total_keys }); + } + + let scan_count = count.max(1); let raw: RedisRawValue = redis::cmd("SCAN") .arg(cursor) .arg("MATCH") .arg(pattern) .arg("COUNT") - .arg(count) + .arg(scan_count) .query_async(con) .await .map_err(|e| e.to_string())?; let (next_cursor, keys) = parse_scan_keys(raw)?; - let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0); - if keys.is_empty() || query.trim().is_empty() { - return Ok(RedisScanResult { cursor: next_cursor, keys: Vec::new(), total_keys }); - } - let mut result = Vec::new(); for key in keys { let Ok(value) = get_value(con, &key).await else { diff --git a/packages/app-tests/redisValueSearch.test.ts b/packages/app-tests/redisValueSearch.test.ts index f5a136ac04..a2ac313654 100644 --- a/packages/app-tests/redisValueSearch.test.ts +++ b/packages/app-tests/redisValueSearch.test.ts @@ -11,3 +11,21 @@ test("Redis browser exposes key/value search modes", () => { assert.match(source, /redis\.searchByKey/); assert.match(source, /redis\.searchByValue/); }); + +test("Redis command input is visually distinct from search", () => { + const source = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8"); + + assert.match(source, /data-redis-command-input/); + assert.match(source, /redis\.commandPrefix/); + assert.match(source, /redis\.commandHint/); +}); + +test("Redis value search streams incremental scan pages from the browser", () => { + const browserSource = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8"); + const driverSource = readFileSync("crates/dbx-core/src/db/redis_driver.rs", "utf8"); + + assert.match(browserSource, /async function streamValueSearch/); + assert.match(browserSource, /searchRequestId/); + assert.match(browserSource, /redis\.searchingValues/); + assert.doesNotMatch(driverSource, /while\s+result\.len\(\)\s*<\s*target_count/); +});