feat(redis): add fetch-all keys button and incremental namespace tree merging

- Add "Fetch All" button alongside "Load More" to continuously SCAN until all keys loaded
- Add stop button and scan progress display during fetch-all
- Implement mergeKeysIntoRedisKeyTree for incremental namespace tree building
- Add toast notifications for empty form validation in key detail panel
- Add i18n translations for new UI elements (zh-CN, zh-TW, en, es)
- Add unit tests for incremental tree merge covering 6 scenarios
This commit is contained in:
t8y2 2026-06-05 13:57:26 +08:00
parent e780e66e41
commit b4a207fc6b
8 changed files with 308 additions and 48 deletions

View File

@ -36,6 +36,7 @@ import {
collectExpandedGroupIds,
collectRedisGroupKeyRaws,
flattenVisibleRedisKeyTree,
mergeKeysIntoRedisKeyTree,
type RedisKeyTreeNode,
} from "@/lib/redisKeyTree";
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
@ -43,9 +44,11 @@ import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { useToast } from "@/composables/useToast";
import { redisKeySearchPattern } from "@/lib/redisKeyPattern";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
@ -70,6 +73,7 @@ const flatKeys = ref<RedisKeyInfo[]>([]);
const treeKeys = ref<RedisKeyTreeNode[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const isFetchingAll = ref(false);
const rootRef = ref<HTMLElement>();
const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref("");
@ -118,6 +122,14 @@ const searchPlaceholder = computed(() =>
const loadingEmptyText = computed(() =>
searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"),
);
const lastTotalKeys = ref(0);
const fetchAllProgressText = computed(() => {
if (!isFetchingAll.value) return "";
if (lastTotalKeys.value > 0) {
return t("redis.fetchAllProgress", { loaded: flatKeys.value.length, total: lastTotalKeys.value });
}
return t("redis.fetchAllProgressUnknown", { loaded: flatKeys.value.length });
});
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
@ -174,6 +186,22 @@ function rebuildTree(expandAll = false) {
}
}
function mergeTree(newKeys: RedisKeyInfo[]) {
if (newKeys.length === 0) return;
treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db);
const availableExpanded = collectExpandedGroupIds(treeKeys.value);
const nextExpanded = new Set<string>();
for (const id of expandedGroupIds.value) {
if (availableExpanded.has(id)) nextExpanded.add(id);
}
expandedGroupIds.value = nextExpanded;
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
}
async function fetchScanPage(): Promise<RedisScanResult> {
const pageSize = settingsStore.editorSettings.redisScanPageSize;
return searchMode.value === "value"
@ -183,10 +211,18 @@ async function fetchScanPage(): Promise<RedisScanResult> {
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))];
const newKeys = result.keys.filter((key) => !existingKeys.has(key.key_raw));
flatKeys.value = [...flatKeys.value, ...newKeys];
scanCursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
rebuildTree(isSearchMode.value);
lastTotalKeys.value = result.total_keys;
if (treeKeys.value.length === 0) {
rebuildTree(isSearchMode.value);
} else {
mergeTree(newKeys);
}
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
total: result.total_keys,
@ -229,6 +265,7 @@ async function fillInitialKeyBatch(requestId: number) {
async function loadKeys() {
if (!redisBrowserIsActive) return;
const requestId = ++searchRequestId;
isFetchingAll.value = false;
loading.value = true;
flatKeys.value = [];
treeKeys.value = [];
@ -267,6 +304,26 @@ async function loadMore() {
}
}
async function fetchAll() {
if (!hasMore.value || isFetchingAll.value) return;
const requestId = searchRequestId;
isFetchingAll.value = true;
try {
while (requestId === searchRequestId && isFetchingAll.value && hasMore.value) {
const applied = await scanNextPage(requestId);
if (!applied) break;
}
} finally {
if (requestId === searchRequestId) {
isFetchingAll.value = false;
}
}
}
function stopFetchAll() {
isFetchingAll.value = false;
}
function toggleGroup(groupId: string) {
const next = new Set(expandedGroupIds.value);
if (next.has(groupId)) next.delete(groupId);
@ -438,15 +495,18 @@ async function createRedisKey() {
const keyName = createKeyName.value.trim();
if (!keyName) {
createKeyError.value = t("redis.createKeyNameRequired");
toast(t("redis.createKeyNameRequired"), 3000);
return;
}
if (createKeyType.value === "hash" && !createKeyField.value.trim()) {
createKeyError.value = t("redis.createFieldRequired");
toast(t("redis.createFieldRequired"), 3000);
return;
}
const score = Number.parseFloat(createKeyScore.value || "0");
if (createKeyType.value === "zset" && Number.isNaN(score)) {
createKeyError.value = t("redis.createScoreInvalid");
toast(t("redis.createScoreInvalid"), 3000);
return;
}
@ -612,6 +672,7 @@ function unregisterRedisDbFlushedListener() {
function pauseRedisBrowserBackgroundWork() {
redisBrowserIsActive = false;
searchRequestId++;
isFetchingAll.value = false;
loading.value = false;
loadingMore.value = false;
if (searchTimer) clearTimeout(searchTimer);
@ -813,17 +874,34 @@ defineExpose({ focusSearch });
</div>
</template>
</RecycleScroller>
<div v-if="hasMore" class="shrink-0 border-t px-2 py-1.5 flex items-center justify-center">
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="h-7 text-xs w-full"
class="h-7 text-xs flex-1"
:disabled="loadingMore || loading"
@click="loadMore"
>
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
<Button
variant="outline"
size="sm"
class="h-7 text-xs flex-1"
:disabled="loading || !hasMore"
@click="fetchAll"
>
{{ t("redis.fetchAllKeys") }}
</Button>
</div>
<div v-if="isFetchingAll" class="shrink-0 border-t px-2 py-1.5 space-y-1">
<div class="text-xs text-muted-foreground text-center">
{{ fetchAllProgressText }}
</div>
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" @click="stopFetchAll">
{{ t("redis.stopFetchAll") }}
</Button>
</div>
</div>
</Pane>

View File

@ -594,7 +594,10 @@ function startEditTtl() {
async function saveTtl() {
const val = ttlInput.value.trim();
const ttl = val === "" || val === "-1" ? -1 : parseInt(val, 10);
if (isNaN(ttl)) return;
if (isNaN(ttl)) {
toast(t("redis.ttlInvalid"), 3000);
return;
}
await api.redisSetTtl(props.connectionId, props.db, props.keyRaw, ttl);
editingTtl.value = false;
await load();
@ -606,7 +609,10 @@ function cancelEditTtl() {
// Hash
async function hashSet() {
if (!newField.value) return;
if (!newField.value.trim()) {
toast(t("redis.fieldRequired"), 3000);
return;
}
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, newField.value, newValue.value);
newField.value = "";
newValue.value = "";
@ -623,7 +629,10 @@ function requestHashDel(field: string) {
// List
async function listPush() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.valueRequired"), 3000);
return;
}
await api.redisListPush(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
@ -639,7 +648,10 @@ function requestListRemove(index: number) {
// Set
async function setAdd() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.memberRequired"), 3000);
return;
}
await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
@ -655,7 +667,10 @@ function requestSetRemove(member: string) {
// ZSet
async function zsetAdd() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.memberRequired"), 3000);
return;
}
const score = parseFloat(newScore.value || "0");
await api.redisZadd(props.connectionId, props.db, props.keyRaw, newValue.value, score);
newValue.value = "";

View File

@ -1243,6 +1243,10 @@ export default {
loadingKeys: "Loading keys...",
searchingValues: "Searching values...",
loadMoreKeys: "Load more keys",
fetchAllKeys: "Fetch all",
stopFetchAll: "Stop",
fetchAllProgress: "{loaded} of {total} keys loaded",
fetchAllProgressUnknown: "{loaded} keys loaded",
items: "{count} items",
fields: "{count} fields",
members: "{count} members",
@ -1291,6 +1295,10 @@ export default {
createKeyNameRequired: "Enter a key name",
createFieldRequired: "Enter a hash field",
createScoreInvalid: "Enter a valid score",
fieldRequired: "Enter a field name",
valueRequired: "Enter a value",
memberRequired: "Enter a member",
ttlInvalid: "Enter a valid TTL in seconds (-1 for no expiry)",
member: "Member",
memberDetail: "Member detail",
viewMember: "View full value",

View File

@ -1135,6 +1135,10 @@ export default {
loadingKeys: "Cargando claves...",
searchingValues: "Buscando por valor...",
loadMoreKeys: "Cargar más claves",
fetchAllKeys: "Cargar todas",
stopFetchAll: "Detener",
fetchAllProgress: "{loaded} de {total} claves cargadas",
fetchAllProgressUnknown: "{loaded} claves cargadas",
items: "{count} elementos",
fields: "{count} campos",
members: "{count} miembros",
@ -1184,6 +1188,10 @@ export default {
createKeyNameRequired: "Ingresa una clave",
createFieldRequired: "Ingresa un campo hash",
createScoreInvalid: "Ingresa una puntuación válida",
fieldRequired: "Ingresa un nombre de campo",
valueRequired: "Ingresa un valor",
memberRequired: "Ingresa un miembro",
ttlInvalid: "Ingresa un TTL válido en segundos (-1 para sin caducidad)",
member: "Miembro",
memberDetail: "Detalle del miembro",
viewMember: "Ver valor completo",

View File

@ -1219,6 +1219,10 @@ export default {
loadingKeys: "正在加载 key...",
searchingValues: "正在按值搜索...",
loadMoreKeys: "加载更多",
fetchAllKeys: "获取全部",
stopFetchAll: "停止",
fetchAllProgress: "已加载 {loaded} / 共 {total} 条 key",
fetchAllProgressUnknown: "已加载 {loaded} 条 key",
items: "{count} 个元素",
fields: "{count} 个字段",
members: "{count} 个成员",
@ -1266,6 +1270,10 @@ export default {
createKeyNameRequired: "请输入 key 名称",
createFieldRequired: "请输入 hash 字段",
createScoreInvalid: "请输入有效分数",
fieldRequired: "请输入字段名",
valueRequired: "请输入值",
memberRequired: "请输入成员",
ttlInvalid: "请输入有效的 TTL秒数-1 表示永不过期)",
member: "成员",
memberDetail: "成员详情",
viewMember: "查看完整内容",

View File

@ -1199,6 +1199,10 @@ export default {
loadingKeys: "正在載入 key……",
searchingValues: "正在按值搜尋……",
loadMoreKeys: "載入更多",
fetchAllKeys: "取得全部",
stopFetchAll: "停止",
fetchAllProgress: "已載入 {loaded} / 共 {total} 條 key",
fetchAllProgressUnknown: "已載入 {loaded} 條 key",
items: "{count} 個元素",
fields: "{count} 個欄位",
members: "{count} 個成員",
@ -1246,6 +1250,10 @@ export default {
createKeyNameRequired: "請輸入 key 名稱",
createFieldRequired: "請輸入 hash 欄位",
createScoreInvalid: "請輸入有效分數",
fieldRequired: "請輸入欄位名稱",
valueRequired: "請輸入值",
memberRequired: "請輸入成員",
ttlInvalid: "請輸入有效的 TTL秒數-1 表示永不過期)",
member: "成員",
memberDetail: "成員詳情",
viewMember: "檢視完整內容",

View File

@ -58,48 +58,24 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
for (const key of keys) {
const pathSegments = key.key_display.split(":");
if (pathSegments.length === 1) {
root.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[0],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
keyType: key.key_type,
ttl: key.ttl,
size: key.size,
valuePreview: key.value_preview,
pathSegments,
});
continue;
}
insertKeyIntoTree(root, groupMap, key, db);
}
let currentLevel = root;
const groupSegments: string[] = [];
for (const segment of pathSegments.slice(0, -1)) {
groupSegments.push(segment);
const groupId = buildGroupId(db, groupSegments);
let group = groupMap.get(groupId);
if (!group) {
group = {
kind: "group",
id: groupId,
label: segment,
pathSegments: [...groupSegments],
children: [],
};
groupMap.set(groupId, group);
currentLevel.push(group);
}
currentLevel = group.children;
}
return sortRedisTreeNodes(root);
}
currentLevel.push({
function insertKeyIntoTree(
root: RedisKeyTreeNode[],
groupMap: Map<string, RedisKeyTreeGroupNode>,
key: RedisKeyInfo,
db: number,
): void {
const pathSegments = key.key_display.split(":");
if (pathSegments.length === 1) {
root.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[pathSegments.length - 1],
label: pathSegments[0],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
@ -109,9 +85,86 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
valuePreview: key.value_preview,
pathSegments,
});
return;
}
return sortRedisTreeNodes(root);
let currentLevel = root;
const groupSegments: string[] = [];
for (const segment of pathSegments.slice(0, -1)) {
groupSegments.push(segment);
const groupId = buildGroupId(db, groupSegments);
let group = groupMap.get(groupId);
if (!group) {
group = {
kind: "group",
id: groupId,
label: segment,
pathSegments: [...groupSegments],
children: [],
};
groupMap.set(groupId, group);
currentLevel.push(group);
}
currentLevel = group.children;
}
currentLevel.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[pathSegments.length - 1],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
keyType: key.key_type,
ttl: key.ttl,
size: key.size,
valuePreview: key.value_preview,
pathSegments,
});
}
function rebuildGroupMap(tree: RedisKeyTreeNode[]): Map<string, RedisKeyTreeGroupNode> {
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
const walk = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "group") {
groupMap.set(node.id, node);
walk(node.children);
}
}
};
walk(tree);
return groupMap;
}
export function mergeKeysIntoRedisKeyTree(
existingTree: RedisKeyTreeNode[],
newKeys: RedisKeyInfo[],
db: number,
): RedisKeyTreeNode[] {
if (existingTree.length === 0) return buildRedisKeyTree(newKeys, db);
const groupMap = rebuildGroupMap(existingTree);
const existingKeyIds = new Set<string>();
const collectKeys = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "leaf") {
existingKeyIds.add(node.keyRaw);
} else {
collectKeys(node.children);
}
}
};
collectKeys(existingTree);
for (const key of newKeys) {
if (existingKeyIds.has(key.key_raw)) continue;
insertKeyIntoTree(existingTree, groupMap, key, db);
}
return sortRedisTreeNodes(existingTree);
}
export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string> {

View File

@ -5,6 +5,7 @@ import {
collectRedisGroupKeyRaws,
collectExpandedGroupIds,
flattenVisibleRedisKeyTree,
mergeKeysIntoRedisKeyTree,
type RedisKeyTreeNode,
} from "../../apps/desktop/src/lib/redisKeyTree.ts";
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/api.ts";
@ -94,3 +95,84 @@ test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
});
test("mergeKeysIntoRedisKeyTree adds new leaf to existing group", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:email", "k2")], 0);
const userGroup = merged.find((node) => node.kind === "group" && node.label === "user");
assert.ok(userGroup);
if (!userGroup || userGroup.kind !== "group") return;
const profileGroup = userGroup.children.find((node) => node.kind === "group" && node.label === "profile");
assert.ok(profileGroup);
if (!profileGroup || profileGroup.kind !== "group") return;
assert.equal(profileGroup.children.length, 2);
const labels = profileGroup.children.map((node) => (node.kind === "leaf" ? node.label : ""));
assert.deepEqual(labels, ["email", "name"]);
});
test("mergeKeysIntoRedisKeyTree creates new group for new prefix", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("session:1", "k2")], 0);
assert.equal(merged.length, 2);
const labels = merged.map((node) => node.label);
assert.deepEqual(labels, ["session", "user"]);
});
test("mergeKeysIntoRedisKeyTree handles root-level keys", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("standalone", "k2")], 0);
assert.equal(merged.length, 2);
const rootLeaf = merged.find((node) => node.kind === "leaf");
assert.ok(rootLeaf);
if (!rootLeaf || rootLeaf.kind !== "leaf") return;
assert.equal(rootLeaf.label, "standalone");
});
test("mergeKeysIntoRedisKeyTree returns same result as full build", () => {
const batch1 = [makeKey("a:b:c", "k1"), makeKey("a:d", "k2")];
const batch2 = [makeKey("a:b:e", "k3"), makeKey("x", "k4")];
const tree = buildRedisKeyTree(batch1, 0);
const merged = mergeKeysIntoRedisKeyTree(tree, batch2, 0);
const full = buildRedisKeyTree([...batch1, ...batch2], 0);
const toStr = (nodes: RedisKeyTreeNode[]): string =>
JSON.stringify(
nodes.map((node) => {
if (node.kind === "leaf") return { l: node.label, id: node.id };
return { g: node.label, id: node.id, c: toStr(node.children) };
}),
);
assert.equal(toStr(merged), toStr(full));
});
test("mergeKeysIntoRedisKeyTree skips duplicate keys", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:name", "k1")], 0);
const userGroup = merged[0];
assert.ok(userGroup && userGroup.kind === "group");
if (!userGroup || userGroup.kind !== "group") return;
const profileGroup = userGroup.children[0];
assert.ok(profileGroup && profileGroup.kind === "group");
if (!profileGroup || profileGroup.kind !== "group") return;
assert.equal(profileGroup.children.length, 1);
});
test("mergeKeysIntoRedisKeyTree into empty tree falls back to full build", () => {
const merged = mergeKeysIntoRedisKeyTree([], [makeKey("a:b:c", "k1"), makeKey("x", "k2")], 0);
const full = buildRedisKeyTree([makeKey("a:b:c", "k1"), makeKey("x", "k2")], 0);
const toStr = (nodes: RedisKeyTreeNode[]): string =>
JSON.stringify(nodes.map((node) => node.label));
assert.equal(toStr(merged), toStr(full));
});