feat(elasticsearch): render REST JSON responses

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-12 10:49:25 +08:00
1 parent 512f46a835
commit 24c63b6012
15 files changed
+1045 -303

No files matched your search

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Code2, Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
import JsonTree from "./JsonTree.vue";
const props = defineProps<{
status: number;
body: string;
}>();
const { t } = useI18n();
const { toast } = useToast();
const { isDark } = useTheme();
const responseView = ref<"raw" | "json">("json");
const jsonTreeRef = ref<{ refresh: () => void }>();
const jsonHighlighter = ref<JsonHighlighter>();
const parsedBody = computed(() => {
try {
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
} catch {
return { valid: false, value: null };
}
});
const statusClass = computed(() => {
if (props.status >= 500) return "border-destructive/40 bg-destructive/10 text-destructive";
if (props.status >= 400) return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300";
if (props.status >= 300) return "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300";
return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
});
const statusLabel = computed(() => `HTTP ${props.status}`);
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
watch(
() => props.body,
() => {
responseView.value = parsedBody.value.valid ? "json" : "raw";
},
{ immediate: true },
);
watch(responseView, (view) => {
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
});
async function copyResponse() {
try {
await copyToClipboard(props.body);
toast(t("grid.copied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
}
function highlightJson(value: string): string {
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
onMounted(() => {
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
.then((highlight) => {
jsonHighlighter.value = highlight;
})
.catch(() => {
jsonHighlighter.value = undefined;
});
});
</script>
<template>
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
<Code2 class="h-3.5 w-3.5" />
</span>
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
{{ t("redis.rawContent") }}
</button>
<button
type="button"
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="responseView === 'json'"
:disabled="!parsedBody.valid"
@click="responseView = 'json'"
>
{{ t("redis.jsonView") }}
</button>
</div>
</div>
<span class="shrink-0 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium tabular-nums" :class="statusClass" role="status" :aria-label="statusLabel">
{{ statusLabel }}
</span>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyJson')" :aria-label="t('grid.copyJson')" @click="copyResponse">
<Copy class="h-3.5 w-3.5" />
</Button>
</header>
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre-wrap break-words">{{ body }}</pre>
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, ref, shallowRef, watch, type VNodeChild } from "vue";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import { ChevronDown, ChevronRight } from "@lucide/vue";
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
defineOptions({ name: "JsonTree" });
const props = withDefaults(
defineProps<{
value: unknown;
wordWrap?: boolean;
/** Optional Shiki-style inline highlighter retained for Redis value views. */
highlightJson?: (json: string) => string;
/** Number of expanded container levels, with the root counted as level one. */
initialExpandedDepth?: number;
/** Render only viewport rows for large, scrollable JSON responses. */
virtualized?: boolean;
}>(),
{
wordWrap: true,
highlightJson: undefined,
initialExpandedDepth: Number.POSITIVE_INFINITY,
virtualized: false,
},
);
type ExpansionMode = "default" | "all" | "none";
type DynamicScrollerHandle = {
forceUpdate: (clear?: boolean) => void;
scrollToPosition?: (position: number) => void;
};
const expansionMode = ref<ExpansionMode>("default");
const expansionOverrides = shallowRef(new Map<string, boolean>());
const virtualScroller = ref<DynamicScrollerHandle>();
const rootNode = computed(() => createJsonTreeRoot(props.value));
function resetExpansion() {
expansionMode.value = "default";
expansionOverrides.value = new Map();
}
function expandAll() {
expansionMode.value = "all";
expansionOverrides.value = new Map();
}
function collapseAll() {
expansionMode.value = "none";
expansionOverrides.value = new Map();
}
function isNodeExpanded(node: JsonTreeNode): boolean {
const override = expansionOverrides.value.get(node.path);
if (override !== undefined) return override;
if (expansionMode.value === "all") return true;
if (expansionMode.value === "none") return false;
return isJsonTreeInitiallyExpanded(node.depth, props.initialExpandedDepth);
}
function setNodeExpanded(path: string, expanded: boolean) {
const next = new Map(expansionOverrides.value);
next.set(path, expanded);
expansionOverrides.value = next;
}
function toggleNode(node: JsonTreeNode) {
setNodeExpanded(node.path, !isNodeExpanded(node));
}
function scalarClass(value: unknown): string {
if (isLosslessJsonNumber(value) || typeof value === "number") return "json-tree-number";
if (typeof value === "string") return "json-tree-string";
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 (isLosslessJsonNumber(value)) return value.raw;
if (typeof value === "string") return JSON.stringify(value);
if (value === null) return "null";
return String(value);
}
function highlightedJson(value: string): string {
return props.highlightJson?.(value) ?? escapeHtml(value);
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function nodeAccessibleLabel(node: JsonTreeNode): string {
// JSON Pointer paths are language-neutral and uniquely identify each toggle.
return node.path || "$";
}
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 renderJsonNode(node: JsonTreeNode): VNodeChild {
const containerValue = isJsonTreeContainer(node.value) ? node.value : undefined;
const expanded = containerValue !== undefined && isNodeExpanded(node);
const indent = `${node.depth * 16}px`;
const rowChildren: VNodeChild[] = [];
if (containerValue !== undefined) {
const accessibleLabel = nodeAccessibleLabel(node);
rowChildren.push(
h(
"button",
{
type: "button",
class: "json-tree-toggle",
"aria-expanded": expanded,
"aria-label": accessibleLabel,
title: accessibleLabel,
onClick: () => toggleNode(node),
},
[expanded ? h(ChevronDown, { class: "h-3.5 w-3.5", "aria-hidden": "true" }) : h(ChevronRight, { class: "h-3.5 w-3.5", "aria-hidden": "true" })],
),
);
} else {
rowChildren.push(h("span", { class: "json-tree-spacer", "aria-hidden": "true" }));
}
if (node.parentKind !== "root") {
rowChildren.push(node.parentKind === "array" ? h("span", { class: "json-tree-index" }, `[${node.label}]`) : highlightedJsonSpan("json-tree-key", JSON.stringify(node.label)), h("span", { class: "json-tree-punctuation" }, ":"));
}
if (containerValue !== undefined) {
const kind = jsonTreeContainerKind(containerValue);
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"), h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue, expanded)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
} else {
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
}
const children = containerValue !== undefined && expanded ? h("div", { class: "json-tree-children" }, getJsonTreeChildren(node).map(renderJsonNode)) : null;
return h("div", { key: node.path, class: "json-tree-node", "data-json-path": node.path }, [h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, rowChildren), children]);
}
const JsonTreeRoot = defineComponent({
name: "JsonTreeRoot",
setup() {
return () => renderJsonNode(rootNode.value);
},
});
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded));
function refresh(resetScroll = false) {
if (!props.virtualized) return;
void nextTick(() => {
virtualScroller.value?.forceUpdate(true);
if (resetScroll) virtualScroller.value?.scrollToPosition?.(0);
});
}
watch([() => props.value, () => props.initialExpandedDepth], () => {
resetExpansion();
refresh(true);
});
defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
</script>
<template>
<div class="json-tree" :class="{ 'is-nowrap': !wordWrap, 'is-virtualized': virtualized }">
<!-- Virtual rows keep a fully expanded response responsive even when it has thousands of nodes. -->
<DynamicScroller v-if="virtualized" ref="virtualScroller" :items="visibleNodes" :min-item-size="24" :buffer="600" key-field="path" class="json-tree-scroller">
<template #default="{ item: node, active, index }">
<DynamicScrollerItem :item="node" :active="active" :size-dependencies="[wordWrap, node.path, node.label, scalarText(node.value), isNodeExpanded(node)]" :data-index="index">
<div class="json-tree-node" :data-json-path="node.path">
<div class="json-tree-row" :style="{ paddingInlineStart: `${node.depth * 16}px` }">
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<span v-else class="json-tree-spacer" aria-hidden="true" />
<template v-if="node.parentKind !== 'root'">
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
<span class="json-tree-punctuation">:</span>
</template>
<template v-if="isJsonTreeContainer(node.value)">
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value, isNodeExpanded(node)) }}</span>
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
</template>
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<JsonTreeRoot v-else />
</div>
</template>
<style>
.json-tree {
--json-tree-key: #1d4ed8;
--json-tree-string: #15803d;
--json-tree-number: #b45309;
--json-tree-boolean: #7c3aed;
--json-tree-null: #64748b;
color: hsl(var(--foreground));
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json-tree.is-virtualized {
height: 100%;
min-height: 0;
}
.json-tree-scroller {
height: 100%;
min-height: 0;
overflow: auto;
}
.json-tree.is-nowrap {
white-space: pre;
overflow-wrap: normal;
}
.json-tree-row {
display: flex;
min-height: 24px;
align-items: flex-start;
gap: 4px;
border-radius: 4px;
}
.json-tree-row:hover {
background: hsl(var(--muted) / 0.5);
}
.json-tree-toggle {
margin-top: 1px;
display: inline-flex;
height: 18px;
width: 18px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 3px;
color: hsl(var(--muted-foreground));
}
.json-tree-toggle:hover {
background: hsl(var(--accent));
color: hsl(var(--foreground));
}
.json-tree-toggle:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 1px;
}
.json-tree-spacer {
width: 18px;
flex: 0 0 auto;
}
.json-tree-key {
color: var(--json-tree-key);
}
.json-tree-string {
color: var(--json-tree-string);
}
.json-tree-number {
color: var(--json-tree-number);
}
.json-tree-boolean {
color: var(--json-tree-boolean);
}
.json-tree-null {
color: var(--json-tree-null);
font-style: italic;
}
.json-tree-index,
.json-tree-punctuation,
.json-tree-summary {
color: hsl(var(--muted-foreground));
}
.json-tree-bracket {
color: hsl(var(--foreground));
font-weight: 650;
}
.dark .json-tree {
--json-tree-key: #93c5fd;
--json-tree-string: #86efac;
--json-tree-number: #fbbf24;
--json-tree-boolean: #c4b5fd;
--json-tree-null: #94a3b8;
}
</style>
@@ -48,6 +48,7 @@ const DocumentBrowser = defineAsyncComponent(() => import("@/components/document
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
@@ -70,6 +71,7 @@ import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
@@ -321,6 +323,7 @@ const resultRuns = computed(() => resultRunItems(props.activeTab));
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
@@ -339,7 +342,7 @@ const hasTabularResult = computed(() => {
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
const showStandaloneResultToolbar = computed(() => props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const standaloneResultToolbarCompact = computed(() => standaloneResultToolbarWidth.value > 0 && standaloneResultToolbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
@@ -672,6 +675,12 @@ function refreshData(): boolean {
emit("reload");
return true;
}
if (activeElasticsearchJsonResponse.value) {
// Match DataGrid's toolbar refresh intent so multi-result runs are
// refreshed as a group instead of replacing them with the active result.
emit("reload", activeResultSql.value, undefined, undefined, undefined, undefined, undefined, "refresh");
return true;
}
if (!dataGridRef.value) return false;
void dataGridRef.value.onToolbarRefresh();
return true;
@@ -723,7 +732,7 @@ function toggleResultAutoSave() {
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
if (target.closest("[data-grid-root]")) return refreshData();
if (target.closest("[data-grid-root], [data-elasticsearch-json-response-root]")) return refreshData();
if (canReloadUnavailableDataTab(props.activeTab)) return refreshData();
return false;
}
@@ -860,7 +869,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -1012,7 +1021,14 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
<div v-if="hasQueryOutput && showStandaloneResultToolbar" ref="standaloneResultToolbarRef" class="flex min-h-7 shrink-0 items-center border-b bg-muted/20">
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData" :compact="standaloneResultToolbarCompact" @select-view="emit('update:activeOutputView', $event)" />
<QueryResultViewSwitcher
:active-view="activeOutputView"
:can-show-result="canShowResultOutput"
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', $event)"
/>
<QueryResultToolbarActions
class="ml-auto"
:active-view="activeOutputView"
@@ -1037,7 +1053,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:table-error="activeTab.explainTableError"
/>
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result" class="flex-1 min-h-0" :result="activeTab.result" />
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
<div v-if="activeTab.isExecuting" class="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -1075,8 +1091,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
<template v-else>
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
<DataGrid
v-if="activeTab.result && hasTabularResult"
v-else-if="activeTab.result && hasTabularResult"
ref="dataGridRef"
:key="activeResultGridCacheKey"
:cache-key="activeResultGridCacheKey"
@@ -1118,7 +1135,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
<template #result-toolbar-leading="{ compact }">
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData && !activeElasticsearchJsonResponse" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
</template>
<template #result-toolbar-actions="{ compact }">
<QueryResultToolbarActions
@@ -1,247 +0,0 @@
<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";
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
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" && !isLosslessJsonNumber(value);
}
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 (isLosslessJsonNumber(value)) return "json-tree-number";
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 (isLosslessJsonNumber(value)) return value.raw;
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>
@@ -10,13 +10,13 @@ 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 JsonTree from "@/components/common/JsonTree.vue";
import * as api from "@/lib/backend/api";
import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redis/redisJsonHighlighter";
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatTtl } from "@/lib/common/ttlFormat";
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
@@ -99,7 +99,7 @@ const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonView = ref<"raw" | "tree">("raw");
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
const redisJsonHighlighter = ref<JsonHighlighter>();
// Auto-refresh
const autoRefreshEnabled = ref(true);
@@ -1118,7 +1118,7 @@ function formatValue(val: unknown): string {
onMounted(() => {
void load();
void createRedisShikiJsonHighlighter({
void createShikiJsonHighlighter({
appearance: () => redisJsonAppearance.value,
})
.then((highlight) => {
@@ -1196,10 +1196,10 @@ onBeforeUnmount(() => {
</label>
</div>
<div v-if="stringValueView === 'json' && stringValueDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<RedisJsonTree :value="stringValueDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<JsonTree :value="stringValueDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<div v-else-if="stringValueView === 'javaserialize' && stringValueDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<RedisJsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<JsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<div v-else-if="stringValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-4 text-xs leading-5">
<div class="mb-3 flex items-center justify-between text-muted-foreground">
@@ -1255,7 +1255,7 @@ onBeforeUnmount(() => {
</label>
</div>
<div v-if="redisJsonView === 'tree'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<RedisJsonTree :value="redisJsonValue" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<JsonTree :value="redisJsonValue" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<textarea v-else v-model="editValue" class="dbx-editor-font-family flex-1 resize-none bg-background p-4 text-sm outline-none" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'" spellcheck="false" @input="handleJsonInput" />
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
@@ -1574,10 +1574,10 @@ onBeforeUnmount(() => {
</label>
</div>
<div v-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<RedisJsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<JsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<div v-else-if="memberValueView === 'javaserialize' && selectedMemberDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<RedisJsonTree :value="selectedMemberDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<JsonTree :value="selectedMemberDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<div v-else-if="memberValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-5 text-xs leading-5">
<div class="mb-3 flex items-center justify-between text-muted-foreground">