Merge pull request #980 from hocestnonsatis/feat/json-tree-view
feat: add JSON tree view for structured prompts
This commit is contained in:
+5
-1
@@ -30,7 +30,11 @@
|
||||
"close": "Close",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"moreLines": "+{count} more lines"
|
||||
"moreLines": "+{count} more lines",
|
||||
"codeView": "Code",
|
||||
"treeView": "Tree",
|
||||
"expandAll": "Expand All",
|
||||
"collapseAll": "Collapse All"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
|
||||
+114
-25
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { JsonTreeViewWrapper } from "./json-tree-view";
|
||||
|
||||
interface CodeViewProps {
|
||||
content: string;
|
||||
@@ -11,42 +15,127 @@ interface CodeViewProps {
|
||||
fontSize?: "xs" | "sm" | "base";
|
||||
}
|
||||
|
||||
type ViewMode = "code" | "tree";
|
||||
|
||||
export function CodeView({ content, language = "json", className, maxLines, fontSize = "xs" }: CodeViewProps) {
|
||||
const t = useTranslations("common");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("code");
|
||||
const expandAllRef = useRef<(() => void) | undefined>(undefined);
|
||||
const collapseAllRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const isJson = language === "json";
|
||||
const showToggle = isJson && !maxLines; // Only show toggle for JSON when not truncated
|
||||
|
||||
// Try to parse JSON to validate it's valid for tree view
|
||||
let isValidJson = false;
|
||||
if (isJson) {
|
||||
try {
|
||||
JSON.parse(content);
|
||||
isValidJson = true;
|
||||
} catch {
|
||||
isValidJson = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const displayLines = maxLines ? lines.slice(0, maxLines) : lines;
|
||||
const hasMore = maxLines && lines.length > maxLines;
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAllRef.current?.();
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAllRef.current?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<pre suppressHydrationWarning className={cn("font-mono overflow-hidden bg-muted rounded p-2", {
|
||||
"text-xs": fontSize === "xs",
|
||||
"text-sm": fontSize === "sm",
|
||||
"text-base": fontSize === "base",
|
||||
})}>
|
||||
<div className={`language-${language} block`}>
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="select-none text-muted-foreground/50 w-6 text-right pr-2 shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 break-all font-mono">
|
||||
{highlightLine(line, language)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex">
|
||||
<span className="select-none text-muted-foreground/50 w-6 text-right pr-2 shrink-0">
|
||||
...
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("moreLines", { count: lines.length - (maxLines || 0) })}
|
||||
</span>
|
||||
{showToggle && isValidJson && (
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
{/* Code/Tree toggle buttons - left side */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === "code" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("code")}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{t("codeView")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("tree")}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{t("treeView")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse buttons - right side (only in tree view) */}
|
||||
{viewMode === "tree" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 w-7 p-0"
|
||||
title={t("expandAll")}
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 w-7 p-0"
|
||||
title={t("collapseAll")}
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</pre>
|
||||
)}
|
||||
{viewMode === "tree" && isValidJson ? (
|
||||
<JsonTreeViewWrapper
|
||||
content={content}
|
||||
className={className}
|
||||
fontSize={fontSize}
|
||||
onExpandAll={expandAllRef}
|
||||
onCollapseAll={collapseAllRef}
|
||||
/>
|
||||
) : (
|
||||
<pre suppressHydrationWarning className={cn("font-mono overflow-hidden bg-muted rounded p-2", {
|
||||
"text-xs": fontSize === "xs",
|
||||
"text-sm": fontSize === "sm",
|
||||
"text-base": fontSize === "base",
|
||||
})}>
|
||||
<div className={`language-${language} block`}>
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="select-none text-muted-foreground/50 w-6 text-right pr-2 shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 break-all font-mono">
|
||||
{highlightLine(line, language)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex">
|
||||
<span className="select-none text-muted-foreground/50 w-6 text-right pr-2 shrink-0">
|
||||
...
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("moreLines", { count: lines.length - (maxLines || 0) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import type React from "react";
|
||||
import { ChevronRight, ChevronDown, ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface JsonNode {
|
||||
key: string | null;
|
||||
value: unknown;
|
||||
type: "object" | "array" | "string" | "number" | "boolean" | "null";
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface JsonTreeViewProps {
|
||||
data: unknown;
|
||||
className?: string;
|
||||
fontSize?: "xs" | "sm" | "base";
|
||||
maxDepth?: number;
|
||||
onExpandAll?: React.MutableRefObject<(() => void) | undefined>;
|
||||
onCollapseAll?: React.MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
function JsonTreeView({ data, className, fontSize = "xs", maxDepth = 10, onExpandAll, onCollapseAll }: JsonTreeViewProps) {
|
||||
const t = useTranslations("common");
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set(["root"]));
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getNodeType = (value: unknown): JsonNode["type"] => {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (typeof value === "object") return "object";
|
||||
return typeof value as "string" | "number" | "boolean";
|
||||
};
|
||||
|
||||
// Collect all expandable paths recursively
|
||||
const collectExpandablePaths = useCallback((value: unknown, path: string, depth: number = 0): string[] => {
|
||||
const paths: string[] = [];
|
||||
const type = getNodeType(value);
|
||||
|
||||
if ((type === "object" || type === "array") && depth < maxDepth) {
|
||||
paths.push(path);
|
||||
|
||||
if (type === "array") {
|
||||
(value as unknown[]).forEach((item, index) => {
|
||||
paths.push(...collectExpandablePaths(item, `${path}.${index}`, depth + 1));
|
||||
});
|
||||
} else {
|
||||
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
|
||||
paths.push(...collectExpandablePaths(v, `${path}.${k}`, depth + 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}, [maxDepth, getNodeType]);
|
||||
|
||||
const allExpandablePaths = useMemo(() => {
|
||||
return collectExpandablePaths(data, "root");
|
||||
}, [data, collectExpandablePaths]);
|
||||
|
||||
const expandAll = useCallback(() => {
|
||||
setExpandedPaths(new Set(allExpandablePaths));
|
||||
}, [allExpandablePaths]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedPaths(new Set(["root"]));
|
||||
}, []);
|
||||
|
||||
const renderValue = (value: unknown, type: JsonNode["type"]): React.ReactNode => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<span className="text-purple-600 dark:text-purple-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "null":
|
||||
return (
|
||||
<span className="text-red-600 dark:text-red-400">null</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (node: JsonNode, depth: number = 0, isLast: boolean = true): React.ReactNode => {
|
||||
const { key, value, type, path } = node;
|
||||
const isExpanded = expandedPaths.has(path);
|
||||
const isComplex = type === "object" || type === "array";
|
||||
const canExpand = isComplex && depth < maxDepth;
|
||||
|
||||
if (!isComplex) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1 px-2 -mx-2 rounded hover:bg-muted-foreground/10 transition-colors group">
|
||||
{key !== null && (
|
||||
<>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors">{key}</span>
|
||||
<span className="text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors">→</span>
|
||||
</>
|
||||
)}
|
||||
<span className="group-hover:opacity-90 transition-opacity">{renderValue(value, type)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = type === "array"
|
||||
? (value as unknown[]).map((item, index) => ({
|
||||
key: String(index),
|
||||
value: item,
|
||||
type: getNodeType(item),
|
||||
path: `${path}.${index}`,
|
||||
}))
|
||||
: Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: v,
|
||||
type: getNodeType(v),
|
||||
path: `${path}.${k}`,
|
||||
}));
|
||||
|
||||
const itemCount = entries.length;
|
||||
const isEmpty = itemCount === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Node header */}
|
||||
<div className="flex items-center gap-2 py-1 px-2 -mx-2 rounded hover:bg-muted-foreground/10 transition-colors group">
|
||||
{canExpand && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePath(path);
|
||||
}}
|
||||
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted-foreground/30 active:bg-muted-foreground/40 transition-all shrink-0"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!canExpand && <span className="w-4 shrink-0" />}
|
||||
|
||||
{key !== null && (
|
||||
<>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors">{key}</span>
|
||||
{type === "array" && (
|
||||
<span className="text-muted-foreground/50 text-xs group-hover:text-muted-foreground/70 transition-colors">[{itemCount}]</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isExpanded && !isEmpty && (
|
||||
<span className="text-muted-foreground/50 text-xs group-hover:text-muted-foreground/70 transition-colors">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<span className="text-muted-foreground/50 text-xs italic group-hover:text-muted-foreground/70 transition-colors">
|
||||
empty
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && canExpand && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{entries.map((entry, index) => {
|
||||
const isLastEntry = index === entries.length - 1;
|
||||
return (
|
||||
<div key={entry.path} className="relative">
|
||||
{/* Tree connector line */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" style={{ marginLeft: '-1.25rem' }} />
|
||||
{!isLastEntry && (
|
||||
<div
|
||||
className="absolute left-0 w-px bg-border/30"
|
||||
style={{
|
||||
marginLeft: '-1.25rem',
|
||||
top: '1.5rem',
|
||||
bottom: '-0.5rem'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start">
|
||||
{/* Horizontal connector */}
|
||||
<div
|
||||
className="absolute left-0 top-3 w-3 h-px bg-border/30"
|
||||
style={{ marginLeft: '-1.25rem' }}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
{renderNode(entry, depth + 1, isLastEntry)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const rootNode: JsonNode = {
|
||||
key: null,
|
||||
value: data,
|
||||
type: getNodeType(data),
|
||||
path: "root",
|
||||
};
|
||||
|
||||
// Expose expand/collapse functions via useEffect
|
||||
useEffect(() => {
|
||||
if (onExpandAll) {
|
||||
onExpandAll.current = expandAll;
|
||||
}
|
||||
if (onCollapseAll) {
|
||||
onCollapseAll.current = collapseAll;
|
||||
}
|
||||
}, [expandAll, collapseAll, onExpandAll, onCollapseAll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-auto bg-muted rounded-lg p-4 font-mono",
|
||||
{
|
||||
"text-xs": fontSize === "xs",
|
||||
"text-sm": fontSize === "sm",
|
||||
"text-base": fontSize === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{renderNode(rootNode)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JsonTreeViewWrapper({
|
||||
content,
|
||||
className,
|
||||
fontSize = "xs",
|
||||
onExpandAll,
|
||||
onCollapseAll
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
fontSize?: "xs" | "sm" | "base";
|
||||
onExpandAll?: React.MutableRefObject<(() => void) | undefined>;
|
||||
onCollapseAll?: React.MutableRefObject<(() => void) | undefined>;
|
||||
}) {
|
||||
const parsedData = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
if (parsedData === null) {
|
||||
return (
|
||||
<div className={cn("font-mono bg-muted rounded-lg p-4 border border-destructive/50 text-destructive text-sm", className)}>
|
||||
Invalid JSON
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<JsonTreeView
|
||||
data={parsedData}
|
||||
className={className}
|
||||
fontSize={fontSize}
|
||||
onExpandAll={onExpandAll}
|
||||
onCollapseAll={onCollapseAll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user