From ec44e09ec80ae4b17303a28a5307585e3efab54c Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Mon, 3 Aug 2026 20:59:39 +0800 Subject: [PATCH] feat(redis): add decompressed view for compressed values --- .../src/components/redis/RedisValueViewer.vue | 199 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../redis/__tests__/redisCompression.spec.ts | 163 ++++++++++++++ .../desktop/src/lib/redis/redisCompression.ts | 134 ++++++++++++ .../src/lib/redis/redisValuePresentation.ts | 4 +- flake.nix | 2 +- package.json | 1 + pnpm-lock.yaml | 8 + 15 files changed, 551 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/redis/__tests__/redisCompression.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisCompression.ts diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index f731b0997..e5e5d7de8 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -4,7 +4,7 @@ import type { CalendarDateTime } from "@internationalized/date"; import { useI18n } from "vue-i18n"; import { onClickOutside } from "@vueuse/core"; import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller"; -import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue"; +import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, FileArchive } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -29,9 +29,11 @@ import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, c import { canRenderRedisValueFormat, canEditRedisMemberDetail, + decodeRedisBlob, formatRedisMemberDetail, getRedisMemberSelectionKey, isRedisBlob, + parseRedisJsonDetail, preferredRedisValueFormat, REDIS_VALUE_FORMAT_DISPLAY_ORDER, redisBlobText, @@ -47,6 +49,7 @@ import { type RedisCollectionItem, type RedisValueFormat, } from "@/lib/redis/redisValuePresentation"; +import { decompressRedisValue, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/redisCompression"; import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch"; import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue"; import { formatJsonSource } from "@/lib/common/safeJsonFormat"; @@ -164,6 +167,70 @@ const memberValueView = ref(readPreferredRedisValueFormat()); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); +// Decompressed view state. Decompression yields to the event loop so the +// loading state paints before the synchronous bounded inflate runs; the result +// intentionally lives outside the synchronous format pipeline built by +// formatRedisMemberDetail, and the request id guards against stale results when +// the user switches values or formats mid-flight. +type RedisDecompressedState = { status: "idle" } | { status: "loading" } | { status: "success"; text: string; algorithm: RedisDecompressAlgorithm } | { status: "error"; reason: "corrupt" | "limit" }; +const decompressedState = ref({ status: "idle" }); +let decompressRequestId = 0; + +async function runDecompress(bytes: Uint8Array, algorithm?: RedisDecompressAlgorithm) { + const requestId = ++decompressRequestId; + decompressedState.value = { status: "loading" }; + const result = await decompressRedisValue(bytes, algorithm ? { algorithm } : {}); + if (requestId !== decompressRequestId) return; + if (result.ok) decompressedState.value = { status: "success", text: result.text, algorithm: result.algorithm }; + else decompressedState.value = { status: "error", reason: result.reason }; +} + +/** Bytes of whichever decompressed view is active (string value or member), or null when there is nothing to decode. */ +function currentDecompressTargetBytes(): Uint8Array | null { + if (stringValueView.value === "decompressed") { + const blob = stringBlob.value; + return blob ? decodeRedisBlob(blob) : null; + } + if (memberValueView.value === "decompressed") { + const raw = selectedMemberRaw.value; + if (raw == null) return null; + // Plain-string members (not blobs) still attempt decompression so the user + // gets the non-blocking "not compressed" notice instead of silence. + return isRedisBlob(raw) ? decodeRedisBlob(raw) : new TextEncoder().encode(typeof raw === "string" ? raw : formatRedisMemberDetail(raw).rawText); + } + return null; +} + +function refreshDecompressedView(algorithm?: RedisDecompressAlgorithm) { + const bytes = currentDecompressTargetBytes(); + if (bytes) void runDecompress(bytes, algorithm); + else decompressedState.value = { status: "idle" }; +} + +/** Last-resort explicit decode for values that are raw RFC 1951 DEFLATE (never auto-detected). */ +function retryDecompressAsDeflate() { + refreshDecompressedView("deflate"); +} + +const decompressedJsonDetail = computed(() => { + const state = decompressedState.value; + return state.status === "success" ? parseRedisJsonDetail(state.text) : null; +}); + +const decompressedFailureMessage = computed(() => { + const state = decompressedState.value; + if (state.status !== "error") return ""; + if (state.reason === "limit") return t("redis.decompressedLimitExceeded"); + return t("redis.decompressedFailed"); +}); + +/** Format label shows the algorithm that actually succeeded, e.g. "Decompressed (zlib)". */ +const decompressedLabel = computed(() => { + const state = decompressedState.value; + const base = t("redis.decompressedView"); + return state.status === "success" ? `${base} (${state.algorithm})` : base; +}); + // Auto-refresh keeps the displayed TTL moving locally and periodically reloads // the complete key detail. The full reload updates changed values as well as // the authoritative TTL without rebuilding the parent key tree. @@ -333,6 +400,42 @@ const stringBlob = computed(() => { }); const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null)); const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true })); + +// The Decompressed view depends on the value/format refs above, so these +// watchers and computeds live here rather than next to the state declarations. +watch([stringValueView, stringBlob], ([view]) => { + if (view !== "decompressed") return; + refreshDecompressedView(); +}); + +watch([memberValueView, selectedMemberRaw], ([view]) => { + if (view !== "decompressed") return; + refreshDecompressedView(); +}); + +const stringGzipBadge = computed(() => (stringBlob.value ? isGzipMagic(decodeRedisBlob(stringBlob.value)) : false)); +const memberGzipBadge = computed(() => (isRedisBlob(selectedMemberRaw.value) ? isGzipMagic(decodeRedisBlob(selectedMemberRaw.value)) : false)); + +/** Raw fallback shown while Decompressed fails: keep the original content visible, never an error string in its place. */ +const decompressedRawFallbackText = computed(() => { + if (stringValueView.value === "decompressed" && stringValueDetail.value) { + return detailTextForFormat(stringValueDetail.value, stringValueDetail.value.defaultFormat); + } + if (memberValueView.value === "decompressed" && selectedMemberDetail.value) { + return detailTextForFormat(selectedMemberDetail.value, selectedMemberDetail.value.defaultFormat); + } + return ""; +}); + +/** Copy targets the decompressed text while the Decompressed view is showing it. */ +const memberCopyText = computed(() => { + if (memberValueView.value === "decompressed") { + const state = decompressedState.value; + if (state.status === "success") return state.text; + } + return detailTextForFormat(selectedMemberDetail.value, memberValueView.value); +}); + const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light")); const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary)); const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false); @@ -462,10 +565,18 @@ const valueSearchSupported = computed(() => showMemberDetail.value || isStringLi const contentSearchText = computed(() => { if (showMemberDetail.value) { if (isEditingMember.value || isEditingHashJson.value) return memberEditValue.value; + if (memberValueView.value === "decompressed") { + const state = decompressedState.value; + return state.status === "success" ? state.text : ""; + } return detailTextForFormat(selectedMemberDetail.value, memberValueView.value); } if (redisKind.value === "json") return editValue.value; if (!isStringLikeKind.value || !stringValueDetail.value) return ""; + if (stringValueView.value === "decompressed") { + const state = decompressedState.value; + return state.status === "success" ? state.text : ""; + } if (stringValueView.value === "json" && stringValueDetail.value.json) return editValue.value; if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value) return editValue.value; return detailTextForFormat(stringValueDetail.value, stringValueView.value); @@ -946,7 +1057,8 @@ function setStringValueFormat(format: RedisValueFormat) { // Keep a dirty JSON draft marked as json so save still compact-writes after a tab switch. if (!(stringDraftFormat.value === "json" && isStringDraftDirty("json"))) stringDraftFormat.value = "utf8"; } - rememberRedisValueFormat(format); + // Decompressed is a per-value view, never a persisted preference. + if (format !== "decompressed") rememberRedisValueFormat(format); } } @@ -968,7 +1080,8 @@ function setMemberValueFormat(format: RedisValueFormat) { memberDraftFormat.value = "utf8"; } } - rememberRedisValueFormat(format); + // Decompressed is a per-value view, never a persisted preference. + if (format !== "decompressed") rememberRedisValueFormat(format); } } @@ -988,13 +1101,15 @@ function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string { return t("grid.hexViewerHex"); case "base64": return "Base64"; + case "decompressed": + return decompressedLabel.value; default: return rawLabel || t("redis.rawContent"); } } function isTextRedisFormat(format: RedisValueFormat): boolean { - return format === "utf8" || format === "ascii" || format === "binary" || format === "json"; + return format === "utf8" || format === "ascii" || format === "binary" || format === "json" || format === "decompressed"; } function highlightRedisJson(json: string): string { @@ -1216,6 +1331,14 @@ function requestDeleteKey() { async function copyValue() { if (!data.value) return; + // Copy the decompressed text while the Decompressed view is showing it. + if (stringValueView.value === "decompressed") { + const state = decompressedState.value; + if (state.status === "success") { + await copyText(state.text); + return; + } + } const value = data.value.data.kind === "stream" ? { ...data.value, data: { ...data.value.data, entries: streamEntries.value } } : data.value; const text = redisValueCopyText(value, collectionItems.value); try { @@ -2115,6 +2238,10 @@ defineExpose({ focusSearch }); {{ redisFormatLabel(format, stringValueDetail.rawLabel) }} +