Files
dbx/apps/desktop/src/components/redis/RedisJsonTree.vue
T

253 lines
5.8 KiB
Vue

<script setup lang="ts">
import { computed, defineComponent, h, ref, type VNodeChild } from "vue";
import { ChevronDown, ChevronRight } from "@lucide/vue";
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>