feat(redis): add JSON value viewer

This commit is contained in:
t8y2 2026-05-22 10:58:02 +08:00
parent b23988161a
commit ed28040555
9 changed files with 577 additions and 71 deletions

View File

@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, defineComponent, h, ref, type VNodeChild } from "vue";
import { ChevronDown, ChevronRight } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
defineOptions({ name: "RedisJsonTree" });
const props = withDefaults(
defineProps<{
value: unknown;
wordWrap?: boolean;
highlightJson?: (json: string) => string;
}>(),
{
wordWrap: true,
highlightJson: undefined,
},
);
const collapsedPaths = ref(new Set<string>());
type JsonNode = {
key: string;
label: string;
value: unknown;
path: string;
depth: number;
parentKind: "object" | "array" | "root";
};
const rootNode = computed<JsonNode>(() => ({
key: "$",
label: "$",
value: props.value,
path: "$",
depth: 0,
parentKind: "root",
}));
function isContainer(value: unknown): value is Record<string, unknown> | unknown[] {
return value !== null && typeof value === "object";
}
function containerKind(value: unknown): "array" | "object" {
return Array.isArray(value) ? "array" : "object";
}
function childNodes(node: JsonNode): JsonNode[] {
if (!isContainer(node.value)) return [];
if (Array.isArray(node.value)) {
return node.value.map((value, index) => ({
key: String(index),
label: String(index),
value,
path: `${node.path}[${index}]`,
depth: node.depth + 1,
parentKind: "array",
}));
}
return Object.entries(node.value).map(([key, value]) => ({
key,
label: key,
value,
path: `${node.path}.${key}`,
depth: node.depth + 1,
parentKind: "object",
}));
}
function nodeSummary(value: unknown): string {
if (Array.isArray(value)) return `Array(${value.length})`;
if (isContainer(value)) return `Object(${Object.keys(value).length})`;
return "";
}
function scalarClass(value: unknown): string {
if (typeof value === "string") return "json-tree-string";
if (typeof value === "number") return "json-tree-number";
if (typeof value === "boolean") return "json-tree-boolean";
if (value === null) return "json-tree-null";
return "json-tree-string";
}
function scalarText(value: unknown): string {
if (typeof value === "string") return JSON.stringify(value);
if (value === null) return "null";
return String(value);
}
function highlightedJsonSpan(className: string, json: string): VNodeChild {
if (!props.highlightJson) return h("span", { class: className }, json);
return h("span", { class: className, innerHTML: props.highlightJson(json) });
}
function isCollapsed(path: string): boolean {
return collapsedPaths.value.has(path);
}
function toggleCollapsed(path: string) {
const next = new Set(collapsedPaths.value);
if (next.has(path)) next.delete(path);
else next.add(path);
collapsedPaths.value = next;
}
function renderJsonNode(node: JsonNode): VNodeChild {
const children = childNodes(node);
const container = isContainer(node.value);
const collapsed = isCollapsed(node.path);
const indent = `${node.depth * 16}px`;
const rowChildren: VNodeChild[] = [];
if (container) {
rowChildren.push(
h(
Button,
{
variant: "ghost",
size: "icon",
class: "redis-json-toggle",
onClick: () => toggleCollapsed(node.path),
},
() => (collapsed ? h(ChevronRight, { class: "h-3.5 w-3.5" }) : h(ChevronDown, { class: "h-3.5 w-3.5" })),
),
);
} else {
rowChildren.push(h("span", { class: "redis-json-spacer" }));
}
if (node.parentKind !== "root") {
rowChildren.push(
node.parentKind === "array"
? h("span", { class: "redis-json-index" }, `[${node.label}]`)
: highlightedJsonSpan("redis-json-key", JSON.stringify(node.label)),
h("span", { class: "redis-json-colon" }, ":"),
);
}
if (container) {
rowChildren.push(
h("span", { class: `redis-json-bracket is-${containerKind(node.value)}` }, Array.isArray(node.value) ? "[" : "{"),
h("span", { class: "redis-json-summary" }, nodeSummary(node.value)),
h("span", { class: `redis-json-bracket is-${containerKind(node.value)}` }, Array.isArray(node.value) ? "]" : "}"),
);
} else {
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
}
return h("div", { class: "redis-json-node" }, [
h("div", { class: "redis-json-row", style: { paddingLeft: indent } }, rowChildren),
container && !collapsed ? h("div", { class: "redis-json-children" }, children.map(renderJsonNode)) : null,
]);
}
const JsonTreeNode = defineComponent({
name: "JsonTreeNode",
setup() {
return () => renderJsonNode(rootNode.value);
},
});
</script>
<template>
<div class="redis-json-tree" :class="{ 'is-nowrap': !wordWrap }">
<JsonTreeNode />
</div>
</template>
<style scoped>
.redis-json-tree {
color: hsl(var(--foreground));
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.redis-json-tree.is-nowrap {
white-space: pre;
overflow-wrap: normal;
}
.redis-json-row {
display: flex;
min-height: 24px;
align-items: flex-start;
gap: 4px;
}
.redis-json-toggle {
margin-top: 1px;
height: 18px;
width: 18px;
flex: 0 0 auto;
color: hsl(var(--muted-foreground));
}
.redis-json-spacer {
width: 18px;
flex: 0 0 auto;
}
.redis-json-key {
color: #1d4ed8;
}
.redis-json-index,
.redis-json-colon,
.redis-json-summary {
color: hsl(var(--muted-foreground));
}
.redis-json-string {
color: #15803d;
}
.redis-json-number {
color: #b45309;
}
.redis-json-boolean {
color: #7c3aed;
}
.redis-json-null {
color: #64748b;
font-style: italic;
}
.redis-json-bracket {
color: hsl(var(--foreground));
font-weight: 650;
}
:global(.dark) .redis-json-key {
color: #93c5fd;
}
:global(.dark) .redis-json-string {
color: #86efac;
}
:global(.dark) .redis-json-number {
color: #fbbf24;
}
:global(.dark) .redis-json-boolean {
color: #c4b5fd;
}
:global(.dark) .redis-json-null {
color: #94a3b8;
}
</style>

View File

@ -1,26 +1,29 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil } from "lucide-vue-next";
import { Braces, Copy, Eye, FileText, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import RedisJsonTree from "./RedisJsonTree.vue";
import * as api from "@/lib/api";
import type { RedisKeyInfo, RedisValue } from "@/lib/api";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redisJsonHighlighter";
import {
canEditRedisMemberDetail,
clampRedisMemberDetailSheetWidth,
formatRedisMemberDetail,
formatRedisStringValue,
getRedisMemberSelectionKey,
highlightRedisJsonDetail,
} from "@/lib/redisValuePresentation";
const { t } = useI18n();
const { toast } = useToast();
const { isDark } = useTheme();
const props = defineProps<{
connectionId: string;
@ -58,9 +61,21 @@ const isResizingMemberSheet = ref(false);
const hashTableRef = ref<HTMLElement | null>(null);
const hashFieldWidth = ref(280);
const isResizingHashColumns = ref(false);
type RedisValueView = "json" | "raw";
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
const stringValueView = ref<RedisValueView>("raw");
const memberValueView = ref<RedisValueView>("raw");
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
const selectedMemberJsonHtml = computed(() =>
selectedMemberDetail.value.format === "json" ? highlightRedisJsonDetail(selectedMemberDetail.value.text) : "",
const selectedMemberJsonDetail = computed(() => selectedMemberDetail.value.json ?? null);
const stringValueDetail = computed(() =>
data.value?.key_type === "string" ? formatRedisMemberDetail(data.value.value) : null,
);
const stringJsonDetail = computed(() => stringValueDetail.value?.json ?? null);
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
const memberRawJsonHtml = computed(() =>
selectedMemberJsonDetail.value ? highlightRedisJson(selectedMemberJsonDetail.value.rawText) : "",
);
const hashGridStyle = computed(() => ({
gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
@ -90,6 +105,36 @@ type RedisMemberContext =
| { kind: "zset"; member: string; score: number }
| { kind: "stream"; field: string };
function readRedisJsonWordWrap(): boolean {
try {
return localStorage.getItem(REDIS_JSON_WRAP_STORAGE_KEY) !== "false";
} catch {
return true;
}
}
function setRedisJsonWordWrap(value: boolean) {
redisJsonWordWrap.value = value;
try {
localStorage.setItem(REDIS_JSON_WRAP_STORAGE_KEY, value ? "true" : "false");
} catch {
// Ignore storage failures; the toggle still works for the current session.
}
}
function rawRedisValueText(value: unknown): string {
if (typeof value === "string") return value;
return String(value ?? "");
}
function highlightRedisJson(json: string): string {
return redisJsonHighlighter.value?.(json, redisJsonAppearance.value) ?? escapeHtml(json);
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const deleteDetails = computed(() => {
const pending = pendingDelete.value;
if (!pending) return "";
@ -127,7 +172,9 @@ async function load(options: { selectDefaultMember?: boolean } = {}) {
data.value = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
scanCursor.value = data.value.scan_cursor ?? undefined;
if (data.value.key_type === "string") {
editValue.value = formatRedisStringValue(data.value.value);
const detail = formatRedisMemberDetail(data.value.value);
editValue.value = detail.rawText;
stringValueView.value = detail.format === "json" ? "json" : "raw";
clearSelectedMember();
} else if (["list", "set", "zset", "hash"].includes(data.value.key_type)) {
collectionItems.value = Array.isArray(data.value.value) ? [...data.value.value] : [];
@ -202,12 +249,14 @@ function copyMember(value: unknown) {
}
function selectMember(title: string, value: unknown, context: RedisMemberContext) {
const detail = formatRedisMemberDetail(value);
selectedMemberTitle.value = title;
selectedMemberRaw.value = value;
selectedMemberKey.value = getRedisMemberSelectionKey(title, value);
selectedMemberContext.value = context;
isEditingMember.value = false;
memberEditValue.value = formatRedisMemberDetail(value).text;
memberEditValue.value = detail.rawText;
memberValueView.value = detail.format === "json" ? "json" : "raw";
}
function clearSelectedMember() {
@ -479,11 +528,22 @@ async function confirmDelete() {
}
function formatValue(val: any): string {
if (typeof val === "string") return formatRedisStringValue(val);
if (typeof val === "string") return formatRedisMemberDetail(val).text;
return JSON.stringify(val, null, 2);
}
onMounted(load);
onMounted(() => {
void load();
void createRedisShikiJsonHighlighter({
appearance: () => redisJsonAppearance.value,
})
.then((highlight) => {
redisJsonHighlighter.value = highlight;
})
.catch(() => {
redisJsonHighlighter.value = undefined;
});
});
onBeforeUnmount(() => {
stopResizeMemberSheet();
stopResizeHashColumns();
@ -549,9 +609,55 @@ onBeforeUnmount(() => {
<!-- String -->
<div v-if="data.key_type === 'string'" class="flex-1 flex flex-col overflow-hidden">
<div v-if="stringJsonDetail" class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
<div class="flex overflow-hidden rounded-md border bg-muted/20 p-0.5">
<Button
variant="ghost"
size="sm"
class="h-6 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': stringValueView === 'json' }"
@click="stringValueView = 'json'"
>
<Braces class="h-3.5 w-3.5" />
{{ t("redis.jsonView") }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': stringValueView === 'raw' }"
@click="stringValueView = 'raw'"
>
<FileText class="h-3.5 w-3.5" />
{{ t("redis.rawContent") }}
</Button>
</div>
<span class="flex-1" />
<label class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch
size="sm"
:model-value="redisJsonWordWrap"
@update:model-value="setRedisJsonWordWrap(Boolean($event))"
/>
</label>
</div>
<div
v-if="stringJsonDetail && stringValueView === 'json'"
class="min-h-0 flex-1 overflow-auto bg-background p-4 font-mono text-sm leading-6"
>
<RedisJsonTree
:value="stringJsonDetail.value"
:word-wrap="redisJsonWordWrap"
:highlight-json="highlightRedisJson"
/>
</div>
<textarea
v-else
v-model="editValue"
class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none"
:class="{ 'whitespace-pre': stringJsonDetail && !redisJsonWordWrap }"
:readonly="isBinaryStringValue"
@input="handleStringInput"
/>
@ -564,7 +670,7 @@ onBeforeUnmount(() => {
size="sm"
@click="
isEditing = false;
editValue = formatRedisStringValue(data.value);
editValue = rawRedisValueText(data.value);
"
>{{ t("grid.discard") }}</Button
>
@ -921,11 +1027,58 @@ onBeforeUnmount(() => {
class="min-h-0 flex-1 resize-none bg-background p-5 font-mono text-[13px] leading-6 outline-none"
spellcheck="false"
/>
<pre
v-else-if="selectedMemberDetail.format === 'json'"
class="json-viewer min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6"
v-html="selectedMemberJsonHtml"
/>
<template v-else-if="selectedMemberJsonDetail">
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
<div class="flex overflow-hidden rounded-md border bg-muted/20 p-0.5">
<Button
variant="ghost"
size="sm"
class="h-6 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': memberValueView === 'json' }"
@click="memberValueView = 'json'"
>
<Braces class="h-3.5 w-3.5" />
{{ t("redis.jsonView") }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': memberValueView === 'raw' }"
@click="memberValueView = 'raw'"
>
<FileText class="h-3.5 w-3.5" />
{{ t("redis.rawContent") }}
</Button>
</div>
<span class="flex-1" />
<label class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch
size="sm"
:model-value="redisJsonWordWrap"
@update:model-value="setRedisJsonWordWrap(Boolean($event))"
/>
</label>
</div>
<div
v-if="memberValueView === 'json'"
class="min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6"
>
<RedisJsonTree
:value="selectedMemberJsonDetail.value"
:word-wrap="redisJsonWordWrap"
:highlight-json="highlightRedisJson"
/>
</div>
<pre
v-else
class="min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6"
:class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
v-html="memberRawJsonHtml"
></pre>
</template>
<pre
v-else
class="min-h-0 flex-1 overflow-auto bg-background p-5 font-mono text-[13px] leading-6 whitespace-pre-wrap break-words"
@ -955,54 +1108,3 @@ onBeforeUnmount(() => {
</Sheet>
</div>
</template>
<style scoped>
.json-viewer {
tab-size: 2;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
:deep(.json-key) {
color: #7c3aed;
font-weight: 600;
}
:deep(.json-string) {
color: #15803d;
}
:deep(.json-number) {
color: #b45309;
}
:deep(.json-boolean) {
color: #2563eb;
font-weight: 600;
}
:deep(.json-null) {
color: #64748b;
font-style: italic;
}
:global(.dark) :deep(.json-key) {
color: #c4b5fd;
}
:global(.dark) :deep(.json-string) {
color: #86efac;
}
:global(.dark) :deep(.json-number) {
color: #fbbf24;
}
:global(.dark) :deep(.json-boolean) {
color: #93c5fd;
}
:global(.dark) :deep(.json-null) {
color: #94a3b8;
}
</style>

View File

@ -942,6 +942,9 @@ export default {
copyMember: "Copy member",
copied: "Copied",
editMember: "Edit",
jsonView: "JSON view",
rawContent: "Raw content",
wordWrap: "Word wrap",
},
mongo: {
documents: "{count} documents",

View File

@ -840,6 +840,9 @@ export default {
copyMember: "Copiar miembro",
copied: "Copiado",
editMember: "Editar",
jsonView: "Vista JSON",
rawContent: "Contenido original",
wordWrap: "Ajuste de línea",
},
mongo: {
documents: "{count} documentos",

View File

@ -922,6 +922,9 @@ export default {
copyMember: "复制成员",
copied: "已复制",
editMember: "编辑",
jsonView: "JSON 视图",
rawContent: "原始内容",
wordWrap: "自动换行",
},
mongo: {
documents: "{count} 个文档",

View File

@ -0,0 +1,51 @@
import type { AppThemeAppearance } from "@/lib/appTheme";
export type RedisJsonHighlighter = (content: string, appearance?: AppThemeAppearance) => string;
interface RedisShikiJsonHighlighterOptions {
appearance: () => AppThemeAppearance;
}
const SHIKI_THEMES = {
dark: "github-dark",
light: "github-light",
} as const;
type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHighlighterCore>>;
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
export async function createRedisShikiJsonHighlighter(
options: RedisShikiJsonHighlighterOptions,
): Promise<RedisJsonHighlighter> {
const highlighter = await getRedisShikiHighlighter();
return (content, appearance = options.appearance()) =>
highlighter.codeToHtml(content, {
lang: "json",
structure: "inline",
theme: SHIKI_THEMES[appearance],
});
}
function getRedisShikiHighlighter(): Promise<ShikiHighlighter> {
highlighterPromise ??= loadRedisShikiHighlighter();
return highlighterPromise;
}
async function loadRedisShikiHighlighter(): Promise<ShikiHighlighter> {
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, githubDark, githubLight, json] = await Promise.all(
[
import("shiki/core"),
import("shiki/engine/javascript"),
import("shiki/themes/github-dark.mjs"),
import("shiki/themes/github-light.mjs"),
import("shiki/langs/json.mjs"),
],
);
return createHighlighterCore({
engine: createJavaScriptRegexEngine(),
langs: [json.default],
themes: [githubDark.default, githubLight.default],
});
}

View File

@ -2,7 +2,15 @@ export type RedisMemberDetailFormat = "json" | "text";
export interface RedisMemberDetail {
text: string;
rawText: string;
format: RedisMemberDetailFormat;
json?: RedisJsonDetail;
}
export interface RedisJsonDetail {
rawText: string;
formattedText: string;
value: unknown;
}
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
@ -24,14 +32,23 @@ export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: n
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
if (typeof value === "string") {
const formatted = formatRedisJsonString(value);
return formatted ? { text: formatted, format: "json" } : { text: value, format: "text" };
const json = parseRedisJsonDetail(value);
return json
? { text: json.formattedText, rawText: value, format: "json", json }
: { text: value, rawText: value, format: "text" };
}
try {
return { text: JSON.stringify(value, null, 2), format: "json" };
const formattedText = JSON.stringify(value, null, 2);
return {
text: formattedText,
rawText: formattedText,
format: "json",
json: { rawText: formattedText, formattedText, value },
};
} catch {
return { text: String(value), format: "text" };
const text = String(value);
return { text, rawText: text, format: "text" };
}
}
@ -46,16 +63,36 @@ export function formatRedisCommandResult(value: unknown): string {
}
function formatRedisJsonString(value: string): string | null {
return parseRedisJsonDetail(value)?.formattedText ?? null;
}
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (!looksLikeJsonContainer(trimmed)) return null;
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
const parsed = JSON.parse(trimmed);
if (!isJsonContainer(parsed)) return null;
return {
rawText: value,
formattedText: JSON.stringify(parsed, null, 2),
value: parsed,
};
} catch {
return null;
}
}
function looksLikeJsonContainer(value: string): boolean {
return (value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"));
}
function isJsonContainer(value: unknown): boolean {
return value !== null && typeof value === "object";
}
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
return `${title}\n${formatRedisMemberDetail(value).text}`;
}

View File

@ -0,0 +1,41 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { createRedisShikiJsonHighlighter } from "../../apps/desktop/src/lib/redisJsonHighlighter.ts";
const redisViewerSource = readFileSync(
join(process.cwd(), "apps/desktop/src/components/redis/RedisValueViewer.vue"),
"utf8",
);
const jsonTreeSource = readFileSync(join(process.cwd(), "apps/desktop/src/components/redis/RedisJsonTree.vue"), "utf8");
test("Redis string values expose JSON and raw content views", () => {
assert.match(redisViewerSource, /stringValueDetail/);
assert.match(redisViewerSource, /stringValueView === 'json'/);
assert.match(redisViewerSource, /RedisJsonTree/);
assert.match(redisViewerSource, /redis\.jsonView/);
assert.match(redisViewerSource, /redis\.rawContent/);
});
test("Redis JSON tree supports folding and word wrap", () => {
assert.match(jsonTreeSource, /collapsedPaths/);
assert.match(jsonTreeSource, /toggleCollapsed/);
assert.match(jsonTreeSource, /wordWrap/);
assert.match(jsonTreeSource, /ChevronRight/);
assert.match(jsonTreeSource, /ChevronDown/);
});
test("Redis JSON raw content uses Shiki highlighting safely", async () => {
assert.match(redisViewerSource, /createRedisShikiJsonHighlighter/);
assert.match(redisViewerSource, /:highlight-json="highlightRedisJson"/);
assert.match(redisViewerSource, /v-html="memberRawJsonHtml"/);
const highlight = await createRedisShikiJsonHighlighter({ appearance: () => "dark" });
const html = highlight('{"name":"<script>","active":true}');
assert.match(html, /style=/);
assert.match(html, /(?:&lt;|&#x3C;)script(?:&gt;|>)/);
assert.doesNotMatch(html, /<script>/);
assert.doesNotMatch(html, /<pre/);
});

View File

@ -8,12 +8,14 @@ import {
formatRedisStringValue,
getRedisMemberSelectionKey,
highlightRedisJsonDetail,
parseRedisJsonDetail,
} from "../../apps/desktop/src/lib/redisValuePresentation.ts";
test("formats JSON object strings for Redis member details", () => {
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
assert.equal(detail.format, "json");
assert.equal(detail.rawText, '{"id":1,"name":"Ada","tags":["dbx","redis"]}');
assert.equal(detail.text, '{\n "id": 1,\n "name": "Ada",\n "tags": [\n "dbx",\n "redis"\n ]\n}');
});
@ -29,6 +31,18 @@ test("formats JSON string values without changing plain strings", () => {
assert.equal(formatRedisStringValue("plain redis value"), "plain redis value");
});
test("parses Redis JSON details only for object and array containers", () => {
const objectDetail = parseRedisJsonDetail('{"id":1,"name":"Ada"}');
assert.equal(objectDetail?.rawText, '{"id":1,"name":"Ada"}');
assert.equal(objectDetail?.formattedText, '{\n "id": 1,\n "name": "Ada"\n}');
assert.deepEqual(objectDetail?.value, { id: 1, name: "Ada" });
assert.equal(parseRedisJsonDetail("[1,2]")?.formattedText, "[\n 1,\n 2\n]");
assert.equal(parseRedisJsonDetail('"plain json string"'), null);
assert.equal(parseRedisJsonDetail("123"), null);
assert.equal(parseRedisJsonDetail("plain redis value"), null);
});
test("formats Redis command results with JSON strings expanded", () => {
assert.equal(formatRedisCommandResult('{"balance":42,"unit":"USD"}'), '{\n "balance": 42,\n "unit": "USD"\n}');
assert.equal(formatRedisCommandResult(["a", 2]), '[\n "a",\n 2\n]');