From 035fecd17ba9ffbb26e1ca81e13bb0c8490ebdd0 Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:45:10 +0800 Subject: [PATCH] feat(redis): support serialized JSON string presentation --- .../src/components/redis/RedisValueViewer.vue | 8 ++- .../dataGrid/cellDetailPresentation.spec.ts | 11 +++- .../redis/RedisValueViewer.redisJson.spec.ts | 10 ++++ .../redis/redisValuePresentation.spec.ts | 54 +++++++++++++++++++ .../dataGrid/__tests__/dataGridDetail.spec.ts | 4 ++ .../lib/dataGrid/cellDetailPresentation.ts | 27 +++++++++- .../src/lib/dataGrid/dataGridDetail.ts | 5 +- .../src/lib/redis/redisValuePresentation.ts | 44 +++++++++++++-- 8 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 9ffa7f67ea..3637aa006e 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -1346,6 +1346,10 @@ function rememberRedisValueCodec(codec: RedisValueCodec) { function formatJsonText(raw: string, decodeUnicode: boolean): string | null { try { + const detail = parseRedisJsonDetail(raw); + if (detail) { + return decodeUnicode ? decodeJsonUnicodeEscapes(detail.formattedText) : detail.formattedText; + } // Keep Redis JSON baselines source-preserving (duplicate keys, number text), // then apply the display-only unicode decode when the user chose "decoded". const formatted = formatJsonSource(raw, 2); @@ -1739,7 +1743,7 @@ async function saveString() { let value = editValue.value; // Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save. if (stringValueView.value === "json" || stringDraftFormat.value === "json") { - const normalized = normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value)); + const normalized = normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value), stringValueDetail.value?.json?.serializedLevel); if (!normalized.ok) { toast(t("redis.jsonFormatError"), 3000); return; @@ -2188,7 +2192,7 @@ async function saveMemberEdit() { let writeValue = memberEditValue.value; // Hash JSON drafts may still be open under UTF-8 after a format switch; keep compact writes. if (savingHashJson) { - const normalized = normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue)); + const normalized = normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue), selectedMemberDetail.value?.json?.serializedLevel); if (!normalized.ok) { toast(t("redis.jsonFormatError"), 3000); return; diff --git a/apps/desktop/src/lib/__tests__/dataGrid/cellDetailPresentation.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/cellDetailPresentation.spec.ts index 5542255dd2..824587a629 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/cellDetailPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/cellDetailPresentation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compactJsonText, valueEditorActions } from "@/lib/dataGrid/cellDetailPresentation"; +import { compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions } from "@/lib/dataGrid/cellDetailPresentation"; describe("cellDetailPresentation", () => { it("offers compact JSON beside format JSON for editable JSON values", () => { @@ -7,4 +7,13 @@ describe("cellDetailPresentation", () => { expect(valueEditorActions({ canSetNull: false, canFormatJson: false })).toEqual(["restoreOriginal"]); expect(compactJsonText('{\n "name": "DBX",\n "enabled": true\n}')).toBe('{"name":"DBX","enabled":true}'); }); + + it("formats serialized JSON strings into pretty-printed JSON (issue #11636)", () => { + const serialized = '"{\\"abc\\":\\"\\"}"'; + expect(looksLikeJsonContainerText(serialized)).toBe(true); + expect(formatJsonText(serialized)).toBe('{\n "abc": ""\n}'); + + // Scalar string in quotes is not treated as a container + expect(looksLikeJsonContainerText('"hello"')).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts index 2558f11b20..b63c991d37 100644 --- a/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts @@ -29,4 +29,14 @@ describe("native RedisJSON editor", () => { const saved = normalizeRedisJsonDraft(opened.json!.formattedText); expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT }); }); + + it("string and hash serialized JSON editor open+save keeps serialized quotes intact (issue #11636)", () => { + const SERIALIZED_COMPACT = '"{\\"role\\":\\"reader\\",\\"role\\":\\"writer\\"}"'; + const opened = formatRedisMemberDetail(SERIALIZED_COMPACT, { allowJsonText: true }); + expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY); + expect(opened.json?.serializedLevel).toBe(1); + + const saved = normalizeRedisJsonDraft(opened.json!.formattedText, opened.json!.serializedLevel); + expect(saved).toEqual({ ok: true, compactText: SERIALIZED_COMPACT }); + }); }); diff --git a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts index 3d06ba6a25..c23e770244 100644 --- a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts @@ -11,6 +11,7 @@ import { jsonToXmlText, jsonToYamlText, normalizeRedisJsonDraft, + parseRedisJsonDetail, preferredRedisValueFormat, redisClipboardSafeText, redisJsonValueText, @@ -411,4 +412,57 @@ describe("redisValuePresentation", () => { it("uses a valid fallback tag for empty JSON keys", () => { expect(jsonToXmlText({ "": 1 })).toBe('\n\n 1\n'); }); + + it("unwraps serialized JSON strings into structured JSON containers (issue #11636)", () => { + const serializedObject = '"{\\"abc\\":\\"\\"}"'; + const detail = parseRedisJsonDetail(serializedObject); + expect(detail).not.toBeNull(); + expect(detail?.formattedText).toBe('{\n "abc": ""\n}'); + expect(detail?.value).toEqual({ abc: "" }); + expect(detail?.serializedLevel).toBe(1); + + const serializedArray = '"[{\\"id\\":1},{\\"id\\":2}]"'; + const arrayDetail = parseRedisJsonDetail(serializedArray); + expect(arrayDetail).not.toBeNull(); + expect(arrayDetail?.value).toEqual([{ id: 1 }, { id: 2 }]); + expect(arrayDetail?.serializedLevel).toBe(1); + + // Double-serialized JSON string unwraps through multiple levels + const doubleSerialized = JSON.stringify(JSON.stringify(JSON.stringify({ nested: true }))); + const doubleDetail = parseRedisJsonDetail(doubleSerialized); + expect(doubleDetail).not.toBeNull(); + expect(doubleDetail?.value).toEqual({ nested: true }); + expect(doubleDetail?.serializedLevel).toBe(2); + }); + + it("auto-detects serialized JSON containers and sets format to json (issue #11636)", () => { + const memberDetail = formatRedisMemberDetail('"{\\"abc\\":\\"\\"}"', { allowJsonText: true }); + expect(memberDetail.json).toBeDefined(); + expect(isRedisJsonContainerValue(memberDetail.json?.value)).toBe(true); + expect(autoRedisValueFormat(memberDetail, null)).toBe("json"); + }); + + it("normalizes serialized JSON drafts with serializedLevel (issue #11636)", () => { + const draft = '{\n "abc": "xyz"\n}'; + expect(normalizeRedisJsonDraft(draft, 1)).toEqual({ + ok: true, + compactText: '"{\\"abc\\":\\"xyz\\"}"', + }); + expect(normalizeRedisJsonDraft(draft, 2)).toEqual({ + ok: true, + compactText: '"\\"{\\\\\\"abc\\\\\\":\\\\\\"xyz\\\\\\"}\\""', + }); + }); + + it("keeps serialized scalar strings as plain text without unwrapping", () => { + const scalarString = '"hello world"'; + const detail = parseRedisJsonDetail(scalarString); + expect(detail?.formattedText).toBe('"hello world"'); + expect(detail?.value).toBe("hello world"); + expect(detail?.serializedLevel).toBeUndefined(); + expect(isRedisJsonContainerValue(detail?.value)).toBe(false); + + const memberDetail = formatRedisMemberDetail(scalarString, { allowJsonText: true }); + expect(autoRedisValueFormat(memberDetail, null)).toBe("utf8"); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts index c5b7da0a8d..e5166f33ae 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts @@ -171,6 +171,10 @@ describe("Cassandra collection cell detail presentation", () => { expect(detailFor("{'color': 'blue'}")!.formattedJson).toBe(""); expect(detailFor("not a literal", "cassandra")!.formattedJson).toBe(""); }); + + it("formats serialized JSON strings into formattedJson (issue #11636)", () => { + expect(detailFor('"{\\"abc\\":\\"\\"}"')!.formattedJson).toBe('{\n "abc": ""\n}'); + }); }); describe("Mongo collection cell detail presentation", () => { diff --git a/apps/desktop/src/lib/dataGrid/cellDetailPresentation.ts b/apps/desktop/src/lib/dataGrid/cellDetailPresentation.ts index 233d3c9aee..e831bfe7fc 100644 --- a/apps/desktop/src/lib/dataGrid/cellDetailPresentation.ts +++ b/apps/desktop/src/lib/dataGrid/cellDetailPresentation.ts @@ -93,6 +93,19 @@ export function formatJsonText(text: string): string | undefined { if (!trimmed) return undefined; if (trimmed.length > CELL_DETAIL_JSON_FORMAT_MAX_LENGTH) return undefined; try { + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === "string") { + const inner = parsed.trim(); + if (inner.startsWith("{") || inner.startsWith("[")) { + return safeJsonFormat(inner, 2); + } + } + } catch { + // fallback to normal format + } + } return safeJsonFormat(trimmed, 2); } catch { return undefined; @@ -112,5 +125,17 @@ export function compactJsonText(text: string): string | undefined { export function looksLikeJsonContainerText(text: string): boolean { const trimmed = text.trim(); - return trimmed.startsWith("{") || trimmed.startsWith("["); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return true; + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === "string") { + const inner = parsed.trim(); + return inner.startsWith("{") || inner.startsWith("["); + } + } catch { + // not valid JSON string + } + } + return false; } diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 05e2f6624d..c9c81481b2 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -1,7 +1,7 @@ import { cellImagePreviewUrl } from "@/lib/dataGrid/cellImageUrl"; import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { clipboardCellValue, displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; -import { formatJsonText } from "@/lib/dataGrid/cellDetailPresentation"; +import { formatJsonText, looksLikeJsonContainerText } from "@/lib/dataGrid/cellDetailPresentation"; import { cqlLiteralToJsonText } from "@/lib/dataGrid/cqlLiteralJson"; import type { DatabaseType } from "@/types/database"; @@ -307,8 +307,7 @@ function detailFormattedJson(value: string, databaseType: DatabaseType | undefin } function looksLikeJsonContainer(text: string): boolean { - const trimmed = text.trim(); - return trimmed.startsWith("{") || trimmed.startsWith("["); + return looksLikeJsonContainerText(text); } function previewText(text: string): string { diff --git a/apps/desktop/src/lib/redis/redisValuePresentation.ts b/apps/desktop/src/lib/redis/redisValuePresentation.ts index 10732311e1..15ec05e98b 100644 --- a/apps/desktop/src/lib/redis/redisValuePresentation.ts +++ b/apps/desktop/src/lib/redis/redisValuePresentation.ts @@ -62,6 +62,7 @@ export interface RedisJsonDetail { rawText: string; formattedText: string; value: unknown; + serializedLevel?: number; } export type RedisJsonDraftNormalizationResult = @@ -351,12 +352,40 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { try { // Pretty-print from source tokens so duplicate members and number spellings // stay intact when Redis string/hash values open in the JSON editor. - const formattedText = formatJsonSource(trimmed, 2); - const parsed = parseJsonPreservingLargeNumbers(trimmed); + let formattedText = formatJsonSource(trimmed, 2); + let parsed = parseJsonPreservingLargeNumbers(trimmed); + let serializedLevel = 0; + + // Issue #11636: Support serialized JSON strings (e.g. "\"{\\\"abc\\\":\\\"\\\"}\"") + // produced by object mappers / serializers. Unwrap into a structured JSON container + // so it formats as an expandable object/array tree rather than an escaped scalar. + while (typeof parsed === "string") { + const innerTrimmed = parsed.trim(); + if (!innerTrimmed.startsWith("{") && !innerTrimmed.startsWith("[") && !innerTrimmed.startsWith('"')) { + break; + } + try { + const nextFormatted = formatJsonSource(innerTrimmed, 2); + const nextParsed = parseJsonPreservingLargeNumbers(innerTrimmed); + if (typeof nextParsed === "string") { + const nextTrimmed = nextParsed.trim(); + if (!nextTrimmed.startsWith("{") && !nextTrimmed.startsWith("[")) { + break; + } + } + formattedText = nextFormatted; + parsed = nextParsed; + serializedLevel += 1; + } catch { + break; + } + } + return { rawText: value, formattedText, value: parsed, + ...(serializedLevel > 0 ? { serializedLevel } : {}), }; } catch { return null; @@ -366,11 +395,16 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { /** * Validates a JSON editor draft and produces the compact text Redis should * store. Source-preserving minification keeps high-precision numbers and - * duplicate object members intact. + * duplicate object members intact. When serializedLevel > 0 (issue #11636), + * wraps the compact text back into the expected serialized string representation. */ -export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult { +export function normalizeRedisJsonDraft(text: string, serializedLevel = 0): RedisJsonDraftNormalizationResult { try { - return { ok: true, compactText: formatJsonSource(text) }; + let compactText = formatJsonSource(text); + for (let i = 0; i < serializedLevel; i += 1) { + compactText = JSON.stringify(compactText); + } + return { ok: true, compactText }; } catch { return { ok: false, error: "invalid_json" }; }