diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 9e283df50..97db860ae 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -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([]); const treeKeys = ref([]); const loading = ref(false); const loadingMore = ref(false); +const isFetchingAll = ref(false); const rootRef = ref(); const commandTerminalRef = ref(); 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(); + 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 { const pageSize = settingsStore.editorSettings.redisScanPageSize; return searchMode.value === "value" @@ -183,10 +211,18 @@ async function fetchScanPage(): Promise { 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 }); -
+
+ +
+
+
+ {{ fetchAllProgressText }} +
+
diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 51f01f187..640811b97 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -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 = ""; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 51902fb56..2d0342b9f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index e0f7762d8..37e823595 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1a7ee6060..7392618f9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "查看完整内容", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f625e597b..f9442984a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "檢視完整內容", diff --git a/apps/desktop/src/lib/redisKeyTree.ts b/apps/desktop/src/lib/redisKeyTree.ts index 7124fbef2..f1414fe0d 100644 --- a/apps/desktop/src/lib/redisKeyTree.ts +++ b/apps/desktop/src/lib/redisKeyTree.ts @@ -58,48 +58,24 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre const groupMap = new Map(); 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, + 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 { + const groupMap = new Map(); + + 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(); + 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 { diff --git a/packages/app-tests/redisKeyTree.test.ts b/packages/app-tests/redisKeyTree.test.ts index adb23e46f..1df5ab5d6 100644 --- a/packages/app-tests/redisKeyTree.test.ts +++ b/packages/app-tests/redisKeyTree.test.ts @@ -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)); +});