feat(redis): add all search mode

This commit is contained in:
t8y2 2026-06-12 11:32:40 +08:00
parent ca3e3a2a56
commit a4bd62005b
13 changed files with 118 additions and 18 deletions

View File

@ -35,7 +35,7 @@ const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
type RedisSearchMode = "key" | "value";
type RedisSearchMode = "key" | "value" | "all";
type RedisCreateKeyType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json";
interface CreateKeyEntry {
@ -100,10 +100,14 @@ let redisBrowserIsActive = true;
let redisDbFlushedListenerRegistered = false;
const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder")));
const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys")));
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
});
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
watch(redisKeySeparator, () => {
if (flatKeys.value.length > 0) rebuildTree(false);
@ -192,7 +196,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
async function fetchScanPage(): Promise<RedisScanResult> {
const pageSize = settingsStore.editorSettings.redisScanPageSize;
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);
return isValueSearchMode.value ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all") : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
}
function appendScanResult(result: RedisScanResult) {
@ -223,7 +227,7 @@ async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
}
async function streamValueSearch(requestId: number) {
while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) {
while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) {
const applied = await scanNextPage(requestId);
if (!applied) return;
}
@ -255,13 +259,13 @@ async function loadKeys() {
expandedGroupIds.value = new Set();
scanCursor.value = 0;
try {
if (searchMode.value === "value" && !valueQuery.value) {
if (isValueSearchMode.value && !valueQuery.value) {
hasMore.value = false;
return;
}
const applied = await scanNextPage(requestId);
if (applied) {
if (searchMode.value === "value") {
if (isValueSearchMode.value) {
await streamValueSearch(requestId);
} else {
await fillInitialKeyBatch(requestId);
@ -849,6 +853,9 @@ defineExpose({ focusSearch });
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
{{ t("redis.searchByValue") }}
</button>
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
{{ t("redis.searchByAll") }}
</button>
</div>
<Input v-model="searchPattern" data-redis-search-input class="h-6 text-xs border-0 shadow-none focus-visible:ring-0" :placeholder="searchPlaceholder" @input="onSearchInput" @keydown="onSearchKeydown" />
<Button v-if="searchMode === 'key'" variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" :class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('redis.fuzzyMatchTitle')" :aria-pressed="fuzzyKeySearch" @click="toggleFuzzyKeySearch">

View File

@ -1388,11 +1388,14 @@
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Fuzzy match: search keys by plain text contains",
valueSearchPlaceholder: "value contains...",
allSearchPlaceholder: "key or value contains...",
searchByKey: "Key",
searchByValue: "Value",
searchByAll: "All",
keys: "{count} keys",
loadingKeys: "Loading keys...",
searchingValues: "Searching values...",
searchingAll: "Searching keys and values...",
loadMoreKeys: "Load more keys",
fetchAllKeys: "Fetch all",
stopFetchAll: "Stop",

View File

@ -1171,11 +1171,14 @@
fuzzyMatch: "Difusa",
fuzzyMatchTitle: "Coincidencia difusa: busca claves que contengan texto plano",
valueSearchPlaceholder: "el valor contiene...",
allSearchPlaceholder: "la clave o el valor contiene...",
searchByKey: "Clave",
searchByValue: "Valor",
searchByAll: "Todo",
keys: "{count} claves",
loadingKeys: "Cargando claves...",
searchingValues: "Buscando por valor...",
searchingAll: "Buscando claves y valores...",
loadMoreKeys: "Cargar más claves",
fetchAllKeys: "Cargar todas",
stopFetchAll: "Detener",

View File

@ -1281,11 +1281,14 @@
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Corrispondenza fuzzy: cerca le chiavi per testo semplice contenuto",
valueSearchPlaceholder: "il valore contiene...",
allSearchPlaceholder: "la chiave o il valore contiene...",
searchByKey: "Chiave",
searchByValue: "Valore",
searchByAll: "Tutto",
keys: "{count} chiavi",
loadingKeys: "Caricamento chiavi...",
searchingValues: "Ricerca nei valori...",
searchingAll: "Ricerca in chiavi e valori...",
loadMoreKeys: "Carica altre chiavi",
fetchAllKeys: "Recupera tutto",
stopFetchAll: "Interrompi",

View File

@ -1281,11 +1281,14 @@
fuzzyMatch: "Aproximado",
fuzzyMatchTitle: "Correspondência aproximada: pesquisa chaves por texto contido",
valueSearchPlaceholder: "o valor contém...",
allSearchPlaceholder: "a chave ou o valor contém...",
searchByKey: "Chave",
searchByValue: "Valor",
searchByAll: "Tudo",
keys: "{count} chaves",
loadingKeys: "Carregando chaves...",
searchingValues: "Pesquisando valores...",
searchingAll: "Pesquisando chaves e valores...",
loadMoreKeys: "Carregar mais chaves",
fetchAllKeys: "Buscar todas",
stopFetchAll: "Parar",

View File

@ -1387,11 +1387,14 @@
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
valueSearchPlaceholder: "按值内容搜索...",
allSearchPlaceholder: "按 key 或值搜索...",
searchByKey: "键",
searchByValue: "值",
searchByAll: "全部",
keys: "{count} 个 key",
loadingKeys: "正在加载 key...",
searchingValues: "正在按值搜索...",
searchingAll: "正在搜索 key 和值...",
loadMoreKeys: "加载更多",
fetchAllKeys: "获取全部",
stopFetchAll: "停止",

View File

@ -1260,11 +1260,14 @@
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自動按包含關係搜尋 key",
valueSearchPlaceholder: "值包含……",
allSearchPlaceholder: "鍵或值包含……",
searchByKey: "鍵",
searchByValue: "值",
searchByAll: "全部",
keys: "{count} 個 key",
loadingKeys: "正在載入 key……",
searchingValues: "正在按值搜尋……",
searchingAll: "正在搜尋鍵和值……",
loadMoreKeys: "載入更多",
fetchAllKeys: "取得全部",
stopFetchAll: "停止",

View File

@ -1148,8 +1148,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count });
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {

View File

@ -1007,8 +1007,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count });
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {

View File

@ -582,6 +582,7 @@ pub async fn scan_cluster_values_page(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String> {
let master_nodes = cluster_master_nodes(pool).await?;
@ -600,7 +601,7 @@ pub async fn scan_cluster_values_page(
let mut con =
connect_direct_node(endpoint, pool.tls, pool.tls_insecure, &pool.username, &pool.password).await?;
let current_cursor = if index == node_index { node_cursor } else { 0 };
let result = scan_values_page(&mut con, current_cursor, pattern, query, count).await?;
let result = scan_values_page(&mut con, current_cursor, pattern, query, include_key_matches, count).await?;
if !result.keys.is_empty() {
let next_cursor = if result.cursor != 0 {
encode_cluster_cursor(index, result.cursor)?
@ -962,6 +963,7 @@ pub async fn scan_values_page<C>(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String>
where
@ -985,7 +987,47 @@ where
let (next_cursor, keys) = parse_scan_keys(raw)?;
let mut result = Vec::new();
for key in keys {
let keys: Vec<_> = keys
.into_iter()
.map(|key| {
let key_display = redis_key_bytes_to_display(&key);
let key_raw = redis_key_bytes_to_raw(&key);
let key_matches = include_key_matches && redis_key_matches_query(&key_display, &key_raw, query);
(key, key_display, key_raw, key_matches)
})
.collect();
let mut key_match_types = Vec::new();
if include_key_matches {
let mut pipe = redis::pipe();
let mut key_match_count = 0usize;
for (key, _, _, key_matches) in &keys {
if *key_matches {
pipe.cmd("TYPE").arg(key);
key_match_count += 1;
}
}
if key_match_count > 0 {
key_match_types = pipe.query_async(con).await.unwrap_or_default();
}
}
let mut key_match_type_index = 0usize;
for (key, key_display, key_raw, key_matches) in keys {
if key_matches {
let key_type = key_match_types.get(key_match_type_index).cloned().unwrap_or_else(|| "unknown".to_string());
key_match_type_index += 1;
result.push(RedisKeyInfo {
key_display,
key_raw,
value_preview: redis_key_value_preview(&key_type),
key_type,
ttl: -2,
size: 0,
});
continue;
}
let Ok(value) = get_value(con, &key).await else {
continue;
};
@ -1076,6 +1118,15 @@ fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool {
redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase())
}
fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> bool {
let query = query.trim();
if query.is_empty() {
return false;
}
let query = query.to_lowercase();
key_display.to_lowercase().contains(&query) || key_raw.to_lowercase().contains(&query)
}
fn redis_search_value_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
@ -1625,8 +1676,9 @@ mod tests {
parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys,
parse_stream_entries, redis_auth_candidates, redis_command_raw_to_json, redis_database_index,
redis_json_raw_to_json, redis_json_value_preview, redis_key_bytes_to_display, redis_key_bytes_to_raw,
redis_key_raw_to_bytes, redis_key_value_preview, redis_raw_to_json, redis_value_contains_binary,
redis_value_matches_query, RedisAuthCandidate, RedisCommandSafety, RedisNodeEndpoint, RedisRawValue,
redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_raw_to_json,
redis_value_contains_binary, redis_value_matches_query, RedisAuthCandidate, RedisCommandSafety,
RedisNodeEndpoint, RedisRawValue,
};
use crate::models::connection::ConnectionConfig;
use redis::ConnectionAddr;
@ -1767,6 +1819,14 @@ mod tests {
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql"));
}
#[test]
fn matches_redis_keys_case_insensitively() {
assert!(redis_key_matches_query("User:42:Profile", "User:42:Profile", "profile"));
assert!(redis_key_matches_query("binary key", "ff75736572", "FF75"));
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", ""));
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", "order"));
}
#[test]
fn matches_hash_field_name_in_value_search() {
let hash_value = serde_json::json!([

View File

@ -54,6 +54,7 @@ pub async fn redis_scan_values_core(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String> {
let connections = state.connections.read().await;
@ -63,11 +64,12 @@ pub async fn redis_scan_values_core(
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, count).await
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, include_key_matches, count).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, count).await
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, include_key_matches, count)
.await
}
},
_ => Err("Not a Redis connection".to_string()),

View File

@ -46,6 +46,7 @@ pub struct RedisValueScanRequest {
pub cursor: u64,
pub pattern: String,
pub query: String,
pub include_key_matches: Option<bool>,
pub count: usize,
}
@ -192,6 +193,7 @@ pub async fn scan_values(
req.cursor,
&req.pattern,
&req.query,
req.include_key_matches.unwrap_or(false),
req.count,
)
.await

View File

@ -34,9 +34,20 @@ pub async fn redis_scan_values(
cursor: u64,
pattern: String,
query: String,
include_key_matches: Option<bool>,
count: usize,
) -> Result<RedisScanResult, String> {
dbx_core::redis_ops::redis_scan_values_core(&state, &connection_id, db, cursor, &pattern, &query, count).await
dbx_core::redis_ops::redis_scan_values_core(
&state,
&connection_id,
db,
cursor,
&pattern,
&query,
include_key_matches.unwrap_or(false),
count,
)
.await
}
#[tauri::command]