feat(redis): add JSON value viewer
This commit is contained in:
parent
b23988161a
commit
ed28040555
|
|
@ -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>
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -922,6 +922,9 @@ export default {
|
|||
copyMember: "复制成员",
|
||||
copied: "已复制",
|
||||
editMember: "编辑",
|
||||
jsonView: "JSON 视图",
|
||||
rawContent: "原始内容",
|
||||
wordWrap: "自动换行",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 个文档",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, /(?:<|<)script(?:>|>)/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<pre/);
|
||||
});
|
||||
|
|
@ -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]');
|
||||
|
|
|
|||
Loading…
Reference in New Issue