Skip to content

Commit f0b28d4

Browse files
committed
feat: json view component
1 parent 2890e03 commit f0b28d4

File tree

5 files changed

+226
-14
lines changed

5 files changed

+226
-14
lines changed

client/src/components/History.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
22
import { Copy } from "lucide-react";
33
import { useState } from "react";
4+
import JsonView from "./JsonView";
45

56
const HistoryAndNotifications = ({
67
requestHistory,
@@ -75,7 +76,7 @@ const HistoryAndNotifications = ({
7576
</button>
7677
</div>
7778
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
78-
{JSON.stringify(JSON.parse(request.request), null, 2)}
79+
<JsonView data={request.request} />
7980
</pre>
8081
</div>
8182
{request.response && (
@@ -92,11 +93,7 @@ const HistoryAndNotifications = ({
9293
</button>
9394
</div>
9495
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
95-
{JSON.stringify(
96-
JSON.parse(request.response),
97-
null,
98-
2,
99-
)}
96+
<JsonView data={request.response} />
10097
</pre>
10198
</div>
10299
)}
@@ -147,7 +144,9 @@ const HistoryAndNotifications = ({
147144
</button>
148145
</div>
149146
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
150-
{JSON.stringify(notification, null, 2)}
147+
<JsonView
148+
data={JSON.stringify(notification, null, 2)}
149+
/>
151150
</pre>
152151
</div>
153152
)}

client/src/components/JsonView.tsx

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { useState, memo } from "react";
2+
import { JsonValue } from "./DynamicJsonForm";
3+
4+
interface JsonViewProps {
5+
data: JsonValue;
6+
name?: string;
7+
initialExpandDepth?: number;
8+
}
9+
10+
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
11+
const trimmed = str.trim();
12+
if (
13+
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
14+
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
15+
) {
16+
return { success: false, data: str };
17+
}
18+
try {
19+
return { success: true, data: JSON.parse(str) };
20+
} catch {
21+
return { success: false, data: str };
22+
}
23+
}
24+
25+
const JsonView = memo(
26+
({ data, name, initialExpandDepth = 2 }: JsonViewProps) => {
27+
const normalizedData =
28+
typeof data === "string"
29+
? tryParseJson(data).success
30+
? tryParseJson(data).data
31+
: data
32+
: data;
33+
34+
return (
35+
<div className="font-mono text-sm transition-all duration-300">
36+
<JsonNode
37+
data={normalizedData}
38+
name={name}
39+
depth={0}
40+
initialExpandDepth={initialExpandDepth}
41+
/>
42+
</div>
43+
);
44+
},
45+
);
46+
47+
JsonView.displayName = "JsonView";
48+
49+
interface JsonNodeProps {
50+
data: JsonValue;
51+
name?: string;
52+
depth: number;
53+
initialExpandDepth: number;
54+
}
55+
56+
const JsonNode = memo(
57+
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
58+
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
59+
60+
const getDataType = (value: JsonValue): string => {
61+
if (Array.isArray(value)) return "array";
62+
if (value === null) return "null";
63+
return typeof value;
64+
};
65+
66+
const dataType = getDataType(data);
67+
68+
const typeStyleMap: Record<string, string> = {
69+
number: "text-blue-600",
70+
boolean: "text-amber-600",
71+
null: "text-purple-600",
72+
undefined: "text-gray-600",
73+
string: "text-green-600",
74+
default: "text-gray-700",
75+
};
76+
77+
const renderCollapsible = (isArray: boolean) => {
78+
const items = isArray
79+
? (data as JsonValue[])
80+
: Object.entries(data as Record<string, JsonValue>);
81+
const itemCount = items.length;
82+
const isEmpty = itemCount === 0;
83+
84+
const symbolMap = {
85+
open: isArray ? "[" : "{",
86+
close: isArray ? "]" : "}",
87+
collapsed: isArray ? "[ ... ]" : "{ ... }",
88+
empty: isArray ? "[]" : "{}",
89+
};
90+
91+
if (isEmpty) {
92+
return (
93+
<div className="flex items-center">
94+
{name && <span className="mr-1 text-gray-400">{name}:</span>}
95+
<span className="text-gray-500">{symbolMap.empty}</span>
96+
</div>
97+
);
98+
}
99+
100+
return (
101+
<div className="flex flex-col">
102+
<div
103+
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/20"
104+
onClick={() => setIsExpanded(!isExpanded)}
105+
>
106+
{name && (
107+
<span className="mr-1 text-gray-400 group-hover:text-gray-100">
108+
{name}:
109+
</span>
110+
)}
111+
{isExpanded ? (
112+
<span className="text-gray-400 group-hover:text-gray-100">
113+
{symbolMap.open}
114+
</span>
115+
) : (
116+
<>
117+
<span className="text-gray-600 group-hover:text-gray-100">
118+
{symbolMap.collapsed}
119+
</span>
120+
<span className="ml-1 text-gray-700 group-hover:text-gray-100">
121+
{itemCount} {itemCount === 1 ? "item" : "items"}
122+
</span>
123+
</>
124+
)}
125+
</div>
126+
{isExpanded && (
127+
<>
128+
<div className="pl-2 ml-4 border-l border-gray-800">
129+
{isArray
130+
? (items as JsonValue[]).map((item, index) => (
131+
<div key={index} className="my-1">
132+
<JsonNode
133+
data={item}
134+
name={`${index}`}
135+
depth={depth + 1}
136+
initialExpandDepth={initialExpandDepth}
137+
/>
138+
</div>
139+
))
140+
: (items as [string, JsonValue][]).map(([key, value]) => (
141+
<div key={key} className="my-1">
142+
<JsonNode
143+
data={value}
144+
name={key}
145+
depth={depth + 1}
146+
initialExpandDepth={initialExpandDepth}
147+
/>
148+
</div>
149+
))}
150+
</div>
151+
<div className="text-gray-400">{symbolMap.close}</div>
152+
</>
153+
)}
154+
</div>
155+
);
156+
};
157+
158+
const renderString = (value: string) => {
159+
const maxLength = 100;
160+
const isTooLong = value.length > maxLength;
161+
162+
if (!isTooLong) {
163+
return (
164+
<div className="flex mr-1 rounded hover:bg-gray-800/20">
165+
{name && <span className="mr-1 text-gray-400">{name}:</span>}
166+
<span className={typeStyleMap.string}>"{value}"</span>
167+
</div>
168+
);
169+
}
170+
171+
return (
172+
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
173+
{name && (
174+
<span className="mr-1 text-gray-400 group-hover:text-gray-100">
175+
{name}:
176+
</span>
177+
)}
178+
<span
179+
className={`${typeStyleMap.string} cursor-pointer group-hover:text-green-500`}
180+
onClick={() => setIsExpanded(!isExpanded)}
181+
title={isExpanded ? "클릭하여 축소" : "클릭하여 전체 보기"}
182+
>
183+
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
184+
</span>
185+
</div>
186+
);
187+
};
188+
189+
switch (dataType) {
190+
case "object":
191+
case "array":
192+
return renderCollapsible(dataType === "array");
193+
case "string":
194+
return renderString(data as string);
195+
default:
196+
return (
197+
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
198+
{name && <span className="mr-1 text-gray-400">{name}:</span>}
199+
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
200+
{data === null ? "null" : String(data)}
201+
</span>
202+
</div>
203+
);
204+
}
205+
},
206+
);
207+
208+
JsonNode.displayName = "JsonNode";
209+
210+
export default JsonView;

client/src/components/ResourcesTab.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
1515
import ListPane from "./ListPane";
1616
import { useEffect, useState } from "react";
1717
import { useCompletionState } from "@/lib/hooks/useCompletionState";
18+
import JsonView from "./JsonView";
1819

1920
const ResourcesTab = ({
2021
resources,
@@ -215,7 +216,7 @@ const ResourcesTab = ({
215216
</Alert>
216217
) : selectedResource ? (
217218
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100">
218-
{resourceContent}
219+
<JsonView data={resourceContent} />
219220
</pre>
220221
) : selectedTemplate ? (
221222
<div className="space-y-4">

client/src/components/SamplingTab.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
CreateMessageRequest,
66
CreateMessageResult,
77
} from "@modelcontextprotocol/sdk/types.js";
8+
import JsonView from "./JsonView";
89

910
export type PendingRequest = {
1011
id: number;
@@ -44,7 +45,7 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
4445
{pendingRequests.map((request) => (
4546
<div key={request.id} className="p-4 border rounded-lg space-y-4">
4647
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
47-
{JSON.stringify(request.request, null, 2)}
48+
<JsonView data={JSON.stringify(request.request)} />
4849
</pre>
4950
<div className="flex space-x-2">
5051
<Button onClick={() => handleApprove(request.id)}>Approve</Button>

client/src/components/ToolsTab.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AlertCircle, Send } from "lucide-react";
1717
import { useEffect, useState } from "react";
1818
import ListPane from "./ListPane";
1919
import { escapeUnicode } from "@/utils/escapeUnicode";
20+
import JsonView from "./JsonView";
2021

2122
const ToolsTab = ({
2223
tools,
@@ -54,15 +55,15 @@ const ToolsTab = ({
5455
<>
5556
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
5657
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
57-
{escapeUnicode(toolResult)}
58+
<JsonView data={escapeUnicode(toolResult)} />
5859
</pre>
5960
<h4 className="font-semibold mb-2">Errors:</h4>
6061
{parsedResult.error.errors.map((error, idx) => (
6162
<pre
6263
key={idx}
6364
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
6465
>
65-
{escapeUnicode(error)}
66+
<JsonView data={escapeUnicode(error)} />
6667
</pre>
6768
))}
6869
</>
@@ -80,7 +81,7 @@ const ToolsTab = ({
8081
<div key={index} className="mb-2">
8182
{item.type === "text" && (
8283
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
83-
{item.text}
84+
<JsonView data={item.text} />
8485
</pre>
8586
)}
8687
{item.type === "image" && (
@@ -101,7 +102,7 @@ const ToolsTab = ({
101102
</audio>
102103
) : (
103104
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
104-
{escapeUnicode(item.resource)}
105+
<JsonView data={escapeUnicode(item.resource)} />
105106
</pre>
106107
))}
107108
</div>
@@ -113,7 +114,7 @@ const ToolsTab = ({
113114
<>
114115
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
115116
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
116-
{escapeUnicode(toolResult.toolResult)}
117+
<JsonView data={escapeUnicode(toolResult.toolResult)} />
117118
</pre>
118119
</>
119120
);

0 commit comments

Comments
 (0)