feat(redis): stream value search results

This commit is contained in:
t8y2 2026-05-18 21:55:16 +08:00
parent 73cb490189
commit 8a81a01fac
6 changed files with 90 additions and 21 deletions

View File

@ -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<any>(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<RedisScanResult> {
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<boolean> {
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 });
<RefreshCw v-else class="h-3 w-3" />
</Button>
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length })
loading && flatKeys.length === 0 ? loadingEmptyText : t("redis.keys", { count: flatKeys.length })
}}</span>
<Button
v-if="checkedKeys.size > 0"
@ -455,12 +483,19 @@ defineExpose({ focusSearch });
</Button>
</div>
<div class="min-h-9 flex items-center gap-1 px-2 border-b shrink-0">
<div class="min-h-9 flex items-center gap-1 px-2 border-b bg-muted/20 shrink-0">
<Terminal class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span
class="h-5 px-1.5 rounded border bg-background/70 text-[10px] font-medium uppercase tracking-wide text-muted-foreground shrink-0"
>
{{ t("redis.commandPrefix") }}
</span>
<span class="font-mono text-xs text-muted-foreground shrink-0">&gt;</span>
<Input
v-model="commandText"
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono"
:placeholder="t('redis.commandPlaceholder')"
data-redis-command-input
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono bg-transparent"
:placeholder="t('redis.commandHint')"
@keydown.enter="executeCommand"
/>
<Button
@ -515,7 +550,7 @@ defineExpose({ focusSearch });
class="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-xs"
>
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ t("redis.loadingKeys") }}</span>
<span>{{ loadingEmptyText }}</span>
</div>
<RecycleScroller
v-else
@ -602,7 +637,13 @@ defineExpose({ focusSearch });
</template>
</RecycleScroller>
<div v-if="hasMore" class="shrink-0 border-t px-2 py-1.5 flex items-center justify-center">
<Button variant="outline" size="sm" class="h-7 text-xs w-full" :disabled="loadingMore" @click="loadMore">
<Button
variant="outline"
size="sm"
class="h-7 text-xs w-full"
:disabled="loadingMore || loading"
@click="loadMore"
>
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>

View File

@ -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",

View File

@ -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",

View File

@ -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 命令已被阻止",

View File

@ -361,22 +361,23 @@ pub async fn scan_values_page(
query: &str,
count: usize,
) -> Result<RedisScanResult, String> {
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 {

View File

@ -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/);
});