Merge pull request #980 from hocestnonsatis/feat/json-tree-view

feat: add JSON tree view for structured prompts
This commit is contained in:
Fatih Kadir Akın
2025-12-28 14:51:20 +03:00
committed by GitHub
3 changed files with 424 additions and 26 deletions
+5 -1
View File
@@ -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
View File
@@ -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>
);
}
+305
View File
@@ -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}
/>
);
}