diff --git a/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts index 57a6513566..8db77633c9 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts @@ -94,6 +94,18 @@ function stringValue(rawBase64 = "dmFsdWU=", ttl = 60) { }; } +function largeStringValue() { + return { + ...stringValue("cHJldmlldw=="), + data: { + kind: "string" as const, + content: { raw_base64: "cHJldmlldw==", encoding: "utf8" as const }, + total_bytes: 45 * 1024 * 1024, + truncated: true, + }, + }; +} + function listValue(ttl = 60) { return { key_display: "key", @@ -137,6 +149,8 @@ const testI18nMessages = { ttlHour: "{count}h", ttlMinute: "{count}m", ttlSecond: "{count}s", + largeStringPreviewHint: "Only {loaded} of {total} is loaded. Preview is read-only.", + largeStringPreviewActionUnavailable: "Unavailable for large-value previews", }, }, }; @@ -250,6 +264,20 @@ async function setStringDraft(value: string) { } describe("RedisValueViewer expiry saving", () => { + it("renders a large String as a bounded read-only preview", async () => { + mocks.redisGetValue.mockResolvedValueOnce(largeStringValue()); + + mountViewer(vi.fn()); + await settle(); + + expect(document.querySelector("textarea")).toBeNull(); + expect(document.querySelector("[data-redis-large-string-preview]")?.textContent).toContain("Only 7 B of 45.0 MB is loaded"); + expect(document.querySelector("[aria-label='grid.copyValue']")?.disabled).toBe(true); + expect(document.querySelector("[aria-label='redis.copyInsertStatement']")?.disabled).toBe(true); + const gzip = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "GZip"); + expect(gzip?.disabled).toBe(true); + }); + it("opens member UTF-8 editing from blank space without hijacking text double-clicks", async () => { mocks.redisGetValue.mockResolvedValueOnce(listValue()); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 280975c04b..ef162fbc9f 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -64,6 +64,7 @@ import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/l import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker"; import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry"; import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; +import { formatBytes } from "@/lib/database/serverMetrics"; const { t, locale } = useI18n(); const { toast } = useToast(); @@ -439,6 +440,7 @@ const stringBlob = computed(() => { if (!value) return null; return value.data.kind === "string" ? value.data.content : null; }); +const isStringValueTruncated = computed(() => data.value?.data.kind === "string" && Boolean(data.value.data.truncated)); const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null)); const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true })); @@ -569,7 +571,7 @@ const memberCopyText = computed(() => memberDecodedText.value ?? detailTextForFo const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light")); const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary)); const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false); -const canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json")); +const canEditCurrentStringFormat = computed(() => !isStringValueTruncated.value && Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json")); const showStringEditActions = computed(() => canEditCurrentStringFormat.value); const originalStringEditValue = computed(() => (stringBlob.value ? rawRedisValueText(stringBlob.value) : "")); const stringJsonRawBaseline = ref(""); @@ -632,15 +634,26 @@ const zsetGridStyle = computed(() => ({ gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`, })); const metadataSizeLabel = computed(() => { + if (data.value?.data.kind === "string" && data.value.data.total_bytes != null) { + return formatBytes(data.value.data.total_bytes); + } const metadata = props.metadata; const size = metadata?.size ?? 0; if (!metadata || size <= 0) return ""; if (metadata.key_type === "string") { - if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${size} B`; + return formatBytes(size); } return String(size); }); +const largeStringPreviewHint = computed(() => { + const value = data.value; + const loaded = stringValueDetail.value?.byteCount ?? 0; + if (!value || value.data.kind !== "string" || !value.data.truncated) return ""; + if (value.data.total_bytes != null) { + return t("redis.largeStringPreviewHint", { loaded: formatBytes(loaded), total: formatBytes(value.data.total_bytes) }); + } + return t("redis.largeStringPreviewHintUnknown", { loaded: formatBytes(loaded) }); +}); const streamRows = computed(() => { if (redisKind.value !== "stream") return []; return streamEntries.value.map((entry, index) => ({ @@ -1305,6 +1318,7 @@ function setStringValueFormat(format: RedisValueFormat) { } function setStringValueCodec(codec: RedisValueCodec) { + if (isStringValueTruncated.value && codec !== "none") return; stringValueCodec.value = codec; rememberRedisValueCodec(codec); } @@ -1538,6 +1552,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre } if (loadedValue.data.kind === "string") { + if (loadedValue.data.truncated) stringValueCodec.value = "none"; const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText: true }); stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText: true }); stringJsonRawBaseline.value = detail.json?.formattedText ?? ""; @@ -1591,7 +1606,7 @@ async function loadMore() { } async function saveString() { - if (!data.value || !stringBlob.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return; + if (!data.value || !stringBlob.value || isStringValueTruncated.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return; let value = editValue.value; // Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save. @@ -1695,6 +1710,10 @@ async function renameKey() { async function copyValue() { if (!data.value) return; + if (isStringValueTruncated.value) { + toast(t("redis.largeStringPreviewActionUnavailable"), 3000); + return; + } // Copy the decoded text while a codec is decoding the string value. if (stringValueCodec.value !== "none" && stringDecodedText.value != null) { await copyText(stringDecodedText.value); @@ -1805,6 +1824,10 @@ function generateInsertStatements(): string | null { } async function copyInsertStatement() { + if (isStringValueTruncated.value) { + toast(t("redis.largeStringPreviewActionUnavailable"), 3000); + return; + } const stmt = generateInsertStatements(); if (!stmt) { toast(t("redis.copyInsertStatementBinary"), 3000); @@ -2734,8 +2757,10 @@ defineExpose({ focusSearch }); - - + + @@ -2774,11 +2799,21 @@ defineExpose({ focusSearch });
{{ t("redis.codecRowLabel") }}
-
- +