feat(json): add interactive JSON tree viewer with collapse/expand and rainbow colors (#786)
- Add JsonTreeViewer component with collapse/expand for nested objects and arrays - Add rainbow colors by depth level using CSS color-mix() with syntax theme tokens - Add virtualization support (@tanstack/react-virtual) for large JSON files (>200 nodes) - Add JsonTreeView wrapper with Expand All / Collapse All toolbar - Integrate into FilesView: tree/text toggle button, JSON file detection - Integrate into ToolPart: auto-detect JSON tool outputs, render as tree - Integrate into ToolOutputDialog: JSON tree view in expanded dialog - Add jsonTreeUtils.ts: parse, flatten, path utilities No new dependencies - uses existing @tanstack/react-virtual. Colors derived from existing syntax.* theme tokens - works across all themes. Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d9fdd39f99
commit
6180f388e8
@@ -20,10 +20,12 @@ import {
|
||||
renderWebSearchOutput,
|
||||
formatInputForDisplay,
|
||||
parseReadToolOutput,
|
||||
tryParseJsonOutput,
|
||||
} from './toolRenderers';
|
||||
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||
import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -1184,6 +1186,18 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
|
||||
}
|
||||
|
||||
// JSON tree viewer for generic JSON outputs
|
||||
const jsonResult = popup.content ? tryParseJsonOutput(popup.content) : { data: null, isJson: false };
|
||||
if (jsonResult.isJson) {
|
||||
return (
|
||||
<JsonTreeView
|
||||
jsonString={popup.content}
|
||||
initiallyExpandedDepth={3}
|
||||
maxHeight="70vh"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
tryParseJsonOutput,
|
||||
} from '../toolRenderers';
|
||||
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
|
||||
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
@@ -534,6 +536,19 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
}> = ({ output, part, metadata, input, syntaxTheme }) => {
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
|
||||
|
||||
if (jsonResult.isJson) {
|
||||
return (
|
||||
<div className="tool-output-surface p-2 rounded-xl w-full min-w-0">
|
||||
<JsonTreeViewer
|
||||
data={jsonResult.data}
|
||||
initiallyExpandedDepth={1}
|
||||
maxHeight="400px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
|
||||
|
||||
@@ -28,6 +28,36 @@ const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string
|
||||
return formatToolInput(input, toolName || '');
|
||||
};
|
||||
|
||||
export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => {
|
||||
if (!output || typeof output !== 'string') {
|
||||
return { data: null, isJson: false };
|
||||
}
|
||||
|
||||
const trimmed = output.trim();
|
||||
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return { data: null, isJson: false };
|
||||
}
|
||||
|
||||
if (!trimmed.endsWith('}') && !trimmed.endsWith(']')) {
|
||||
return { data: null, isJson: false };
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
return { data: null, isJson: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
return { data: parsed, isJson: true };
|
||||
}
|
||||
return { data: null, isJson: false };
|
||||
} catch {
|
||||
return { data: null, isJson: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
|
||||
let cleaned = cleanOutput(output);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { JsonTreeViewer } from './JsonTreeViewer';
|
||||
|
||||
interface JsonTreeViewProps {
|
||||
jsonString: string;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
initiallyExpandedDepth?: number;
|
||||
}
|
||||
|
||||
const JsonTreeView = React.memo(function JsonTreeView({
|
||||
jsonString,
|
||||
className,
|
||||
maxHeight = '100%',
|
||||
initiallyExpandedDepth = 2,
|
||||
}: JsonTreeViewProps) {
|
||||
const viewerRef = React.useRef<{ expandAll: () => void; collapseAll: () => void }>(null);
|
||||
const [parseError, setParseError] = React.useState<string | null>(null);
|
||||
|
||||
const parsedData = React.useMemo(() => {
|
||||
try {
|
||||
const trimmed = jsonString.trim();
|
||||
if (!trimmed) {
|
||||
setParseError('Empty JSON content');
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
setParseError(null);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Invalid JSON');
|
||||
return null;
|
||||
}
|
||||
}, [jsonString]);
|
||||
|
||||
const handleExpandAll = React.useCallback(() => {
|
||||
viewerRef.current?.expandAll();
|
||||
}, []);
|
||||
|
||||
const handleCollapseAll = React.useCallback(() => {
|
||||
viewerRef.current?.collapseAll();
|
||||
}, []);
|
||||
|
||||
if (parseError) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--syntax-base-background)] p-4">
|
||||
<div className="mb-1 font-medium text-[var(--surface-foreground)]">Invalid JSON</div>
|
||||
<div className="font-mono text-xs text-[var(--surface-mutedForeground)]">{parseError}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center gap-1 border-b border-[var(--interactive-border)] px-2 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleExpandAll}
|
||||
className="gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<RiArrowDownSLine className="h-3 w-3" />
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleCollapseAll}
|
||||
className="gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<RiArrowUpSLine className="h-3 w-3" />
|
||||
Collapse All
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="bg-[var(--syntax-base-background)] py-1"
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<JsonTreeViewer
|
||||
ref={viewerRef}
|
||||
data={parsedData}
|
||||
maxHeight={maxHeight}
|
||||
initiallyExpandedDepth={initiallyExpandedDepth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { JsonTreeView };
|
||||
export type { JsonTreeViewProps };
|
||||
@@ -0,0 +1,304 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import {
|
||||
parseJsonToTree,
|
||||
flattenTree,
|
||||
getAllExpandableIds,
|
||||
getExpandableIdsAboveDepth,
|
||||
type JsonTreeNode,
|
||||
type FlatJsonNode,
|
||||
} from '@/lib/jsonTreeUtils';
|
||||
|
||||
interface JsonTreeViewerProps {
|
||||
data: unknown;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
initiallyExpandedDepth?: number;
|
||||
onCopyPath?: (path: string) => void;
|
||||
}
|
||||
|
||||
const RAINBOW_COLORS = [
|
||||
'var(--syntax-key)',
|
||||
'color-mix(in oklch, var(--syntax-key) 85%, var(--syntax-string))',
|
||||
'color-mix(in oklch, var(--syntax-key) 70%, var(--syntax-number))',
|
||||
'color-mix(in oklch, var(--syntax-key) 55%, var(--syntax-function))',
|
||||
'color-mix(in oklch, var(--syntax-key) 40%, var(--syntax-type))',
|
||||
'color-mix(in oklch, var(--syntax-key) 30%, var(--syntax-keyword))',
|
||||
];
|
||||
|
||||
function getKeyColor(depth: number): string {
|
||||
return RAINBOW_COLORS[depth % RAINBOW_COLORS.length];
|
||||
}
|
||||
|
||||
function getValueColor(node: JsonTreeNode): string {
|
||||
switch (node.type) {
|
||||
case 'string':
|
||||
return 'var(--syntax-string)';
|
||||
case 'number':
|
||||
return 'var(--syntax-number)';
|
||||
case 'boolean':
|
||||
return 'var(--syntax-keyword)';
|
||||
case 'null':
|
||||
return 'var(--syntax-comment)';
|
||||
default:
|
||||
return 'var(--surface-foreground)';
|
||||
}
|
||||
}
|
||||
|
||||
function formatValuePreview(node: JsonTreeNode): string {
|
||||
switch (node.type) {
|
||||
case 'string': {
|
||||
const str = node.value as string;
|
||||
if (str.length > 60) return `"${str.slice(0, 57)}..."`;
|
||||
return `"${str}"`;
|
||||
}
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
return String(node.value);
|
||||
case 'null':
|
||||
return 'null';
|
||||
case 'object':
|
||||
return `{${node.childCount ?? 0} ${node.childCount === 1 ? 'item' : 'items'}}`;
|
||||
case 'array':
|
||||
return `[${node.childCount ?? 0}]`;
|
||||
default:
|
||||
return String(node.value);
|
||||
}
|
||||
}
|
||||
|
||||
function getCollapsedPreview(node: JsonTreeNode): string {
|
||||
if (node.type === 'object') {
|
||||
const keys = node.children?.map((c) => c.key).slice(0, 3) ?? [];
|
||||
const suffix = (node.childCount ?? 0) > 3 ? ', ...' : '';
|
||||
return `{ ${keys.map((k) => `"${k}": ...`).join(', ')}${suffix} }`;
|
||||
}
|
||||
if (node.type === 'array') {
|
||||
const count = node.childCount ?? 0;
|
||||
if (count <= 3) {
|
||||
const items = node.children?.map((c) => formatValuePreview(c)).join(', ') ?? '';
|
||||
return `[${items}]`;
|
||||
}
|
||||
return `[${count} items]`;
|
||||
}
|
||||
return formatValuePreview(node);
|
||||
}
|
||||
|
||||
const JsonRow = React.memo(
|
||||
({
|
||||
flatNode,
|
||||
onToggle,
|
||||
onCopyPath,
|
||||
}: {
|
||||
flatNode: FlatJsonNode;
|
||||
onToggle: (id: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
}) => {
|
||||
const { node, isExpanded } = flatNode;
|
||||
const indent = node.depth * 20;
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
onToggle(node.id);
|
||||
}, [onToggle, node.id]);
|
||||
|
||||
const handleContextMenu = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onCopyPath) {
|
||||
e.preventDefault();
|
||||
onCopyPath(node.id);
|
||||
}
|
||||
},
|
||||
[onCopyPath, node.id],
|
||||
);
|
||||
|
||||
const keyColor = getKeyColor(node.depth);
|
||||
const valueColor = getValueColor(node);
|
||||
const isCollapsible = node.isExpandable && node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center py-0.5 px-2 hover:bg-[var(--surface-hover)] rounded-sm cursor-default font-mono text-xs leading-5 whitespace-nowrap"
|
||||
style={{ paddingLeft: `${indent + 8}px` }}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm hover:bg-[var(--interactive-hover)] text-muted-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3 w-3" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mr-1 w-4" />
|
||||
)}
|
||||
|
||||
{node.key !== 'root' && (
|
||||
<>
|
||||
<span
|
||||
className="mr-1 font-semibold"
|
||||
style={{ color: keyColor }}
|
||||
>
|
||||
{/^\d+$/.test(node.key) ? node.key : `"${node.key}"`}
|
||||
</span>
|
||||
<span className="mr-1 text-[var(--surface-foreground)]">:</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{node.isExpandable ? (
|
||||
isExpanded ? (
|
||||
<span className="text-[var(--surface-foreground)]">
|
||||
{node.type === 'object' ? '{' : '['}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--surface-mutedForeground)' }}>
|
||||
{getCollapsedPreview(node)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span style={{ color: valueColor }}>{formatValuePreview(node)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
JsonRow.displayName = 'JsonRow';
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 200;
|
||||
const ROW_HEIGHT = 22;
|
||||
|
||||
const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () => void }, JsonTreeViewerProps>(
|
||||
function JsonTreeViewer(
|
||||
{ data, className, maxHeight = '100%', initiallyExpandedDepth = 2, onCopyPath },
|
||||
ref,
|
||||
) {
|
||||
const jsonString = React.useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const treeRoot = React.useMemo(() => {
|
||||
if (!jsonString) return null;
|
||||
return parseJsonToTree(jsonString);
|
||||
}, [jsonString]);
|
||||
|
||||
const [collapsedPaths, setCollapsedPaths] = React.useState<Set<string>>(() => {
|
||||
if (!treeRoot) return new Set();
|
||||
return new Set(getExpandableIdsAboveDepth(treeRoot, initiallyExpandedDepth));
|
||||
});
|
||||
|
||||
const flatNodes = React.useMemo(
|
||||
() => flattenTree(treeRoot, collapsedPaths),
|
||||
[treeRoot, collapsedPaths],
|
||||
);
|
||||
|
||||
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatNodes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
const handleToggle = React.useCallback((id: string) => {
|
||||
setCollapsedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = React.useCallback(() => {
|
||||
setCollapsedPaths(new Set());
|
||||
}, []);
|
||||
|
||||
const collapseAll = React.useCallback(() => {
|
||||
if (!treeRoot) return;
|
||||
setCollapsedPaths(new Set(getAllExpandableIds(treeRoot)));
|
||||
}, [treeRoot]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({ expandAll, collapseAll }), [expandAll, collapseAll]);
|
||||
|
||||
if (!treeRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={className}
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const flatNode = flatNodes[virtualRow.index];
|
||||
if (!flatNode) return null;
|
||||
return (
|
||||
<div
|
||||
key={flatNode.node.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<JsonRow
|
||||
flatNode={flatNode}
|
||||
onToggle={handleToggle}
|
||||
onCopyPath={onCopyPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={className}
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
{flatNodes.map((flatNode) => (
|
||||
<JsonRow
|
||||
key={flatNode.node.id}
|
||||
flatNode={flatNode}
|
||||
onToggle={handleToggle}
|
||||
onCopyPath={onCopyPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { JsonTreeViewer };
|
||||
export type { JsonTreeViewerProps };
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
RiEditLine,
|
||||
RiFileCopyLine,
|
||||
RiFileTransferLine,
|
||||
RiCodeSSlashLine,
|
||||
RiNodeTree,
|
||||
} from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
@@ -40,6 +42,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
@@ -263,6 +266,12 @@ const isMarkdownFile = (path: string): boolean => {
|
||||
return ext === 'md' || ext === 'markdown';
|
||||
};
|
||||
|
||||
const isJsonFile = (path: string): boolean => {
|
||||
if (!path) return false;
|
||||
const ext = path.toLowerCase().split('.').pop();
|
||||
return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson';
|
||||
};
|
||||
|
||||
const isHtmlFile = (path: string): boolean => {
|
||||
if (!path) return false;
|
||||
const ext = path.toLowerCase().split('.').pop();
|
||||
@@ -472,6 +481,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
|
||||
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
||||
const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree');
|
||||
const [htmlViewMode, setHtmlViewMode] = React.useState<'preview' | 'edit'>('edit');
|
||||
|
||||
const lightTheme = React.useMemo(
|
||||
@@ -1639,6 +1649,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
||||
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
||||
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
||||
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
||||
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
||||
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
||||
const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview');
|
||||
@@ -1708,6 +1719,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return mdViewMode;
|
||||
}, [mdViewMode]);
|
||||
|
||||
const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode';
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY);
|
||||
if (stored === 'tree') {
|
||||
setJsonViewMode('tree');
|
||||
} else if (stored === 'text') {
|
||||
setJsonViewMode('text');
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode';
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -1723,6 +1749,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => {
|
||||
setJsonViewMode(mode);
|
||||
try {
|
||||
localStorage.setItem(JSON_VIEWER_MODE_KEY, mode);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveHtmlViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
setHtmlViewMode(mode);
|
||||
try {
|
||||
@@ -1735,7 +1770,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const getHtmlViewMode = React.useCallback((): 'preview' | 'edit' => {
|
||||
return htmlViewMode;
|
||||
}, [htmlViewMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingFileNavigation || !root) {
|
||||
return;
|
||||
@@ -2285,6 +2319,22 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isJson && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => saveJsonViewMode(jsonViewMode === 'tree' ? 'text' : 'tree')}
|
||||
className="h-6 w-6 p-0 text-muted-foreground opacity-65 hover:opacity-100"
|
||||
title={jsonViewMode === 'tree' ? 'Switch to Text View' : 'Switch to Tree View'}
|
||||
>
|
||||
{jsonViewMode === 'tree' ? (
|
||||
<RiCodeSSlashLine className="size-4" />
|
||||
) : (
|
||||
<RiNodeTree className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2580,6 +2630,25 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||
/>
|
||||
</div>
|
||||
) : selectedFile && isJson && jsonViewMode === 'tree' ? (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||
<div className="mb-1 font-medium text-destructive">JSON viewer unavailable</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Switch to text mode to view raw content.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto">
|
||||
<JsonTreeView
|
||||
jsonString={fileContent}
|
||||
maxHeight="100%"
|
||||
initiallyExpandedDepth={2}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* JSON Tree utilities for interactive JSON viewing.
|
||||
* Provides parsing, tree building, flattening, and path utilities.
|
||||
*/
|
||||
|
||||
export type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
|
||||
|
||||
export interface JsonTreeNode {
|
||||
id: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
type: JsonTreeNodeType;
|
||||
depth: number;
|
||||
children?: JsonTreeNode[];
|
||||
path: string[];
|
||||
isExpandable: boolean;
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export interface FlatJsonNode {
|
||||
node: JsonTreeNode;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface JsonTreeOptions {
|
||||
maxDepth?: number;
|
||||
maxNodes?: number;
|
||||
initiallyExpandedDepth?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<JsonTreeOptions> = {
|
||||
maxDepth: 50,
|
||||
maxNodes: 100_000,
|
||||
initiallyExpandedDepth: 2,
|
||||
};
|
||||
|
||||
function getType(value: unknown): JsonTreeNodeType {
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
if (typeof value === 'object') return 'object';
|
||||
if (typeof value === 'string') return 'string';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
return 'null';
|
||||
}
|
||||
|
||||
export function getNodePath(pathSegments: string[]): string {
|
||||
if (pathSegments.length === 0) return 'root';
|
||||
let result = 'root';
|
||||
for (const segment of pathSegments) {
|
||||
if (/^\d+$/.test(segment)) {
|
||||
result += `[${segment}]`;
|
||||
} else {
|
||||
result += `.${segment}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseNodePath(pathKey: string): string[] {
|
||||
if (pathKey === 'root' || pathKey === '') return [];
|
||||
const withoutRoot = pathKey.startsWith('root.') ? pathKey.slice(5) : pathKey.startsWith('root[') ? pathKey.slice(4) : pathKey;
|
||||
const segments: string[] = [];
|
||||
let current = '';
|
||||
let i = 0;
|
||||
while (i < withoutRoot.length) {
|
||||
const ch = withoutRoot[i];
|
||||
if (ch === '.') {
|
||||
if (current) segments.push(current);
|
||||
current = '';
|
||||
i++;
|
||||
} else if (ch === '[') {
|
||||
if (current) segments.push(current);
|
||||
current = '';
|
||||
i++;
|
||||
let bracket = '';
|
||||
while (i < withoutRoot.length && withoutRoot[i] !== ']') {
|
||||
bracket += withoutRoot[i];
|
||||
i++;
|
||||
}
|
||||
segments.push(bracket);
|
||||
i++;
|
||||
} else {
|
||||
current += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (current) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
let nodeCount = 0;
|
||||
|
||||
function buildTreeNode(
|
||||
value: unknown,
|
||||
key: string,
|
||||
path: string[],
|
||||
depth: number,
|
||||
options: Required<JsonTreeOptions>,
|
||||
): JsonTreeNode | null {
|
||||
if (nodeCount >= options.maxNodes) return null;
|
||||
nodeCount++;
|
||||
|
||||
const type = getType(value);
|
||||
const id = getNodePath(path);
|
||||
const isExpandable = type === 'object' || type === 'array';
|
||||
|
||||
const node: JsonTreeNode = {
|
||||
id,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
depth,
|
||||
path,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
if (isExpandable && depth < options.maxDepth) {
|
||||
const entries = type === 'array'
|
||||
? (value as unknown[]).map((v, i) => [String(i), v] as const)
|
||||
: Object.entries(value as Record<string, unknown>);
|
||||
|
||||
node.childCount = entries.length;
|
||||
node.children = [];
|
||||
for (const [childKey, childValue] of entries) {
|
||||
const childPath = [...path, childKey];
|
||||
const child = buildTreeNode(childValue, childKey, childPath, depth + 1, options);
|
||||
if (child) node.children.push(child);
|
||||
}
|
||||
} else if (isExpandable) {
|
||||
node.childCount = type === 'array'
|
||||
? (value as unknown[]).length
|
||||
: Object.keys(value as Record<string, unknown>).length;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function parseJsonToTree(text: string, options?: JsonTreeOptions): JsonTreeNode | null {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
nodeCount = 0;
|
||||
|
||||
try {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return buildTreeNode(parsed, 'root', [], 0, opts);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenTree(root: JsonTreeNode | null, collapsedPaths: Set<string>): FlatJsonNode[] {
|
||||
if (!root) return [];
|
||||
|
||||
const result: FlatJsonNode[] = [];
|
||||
|
||||
function walk(node: JsonTreeNode) {
|
||||
const isExpanded = !collapsedPaths.has(node.id);
|
||||
result.push({ node, isExpanded });
|
||||
|
||||
if (node.isExpandable && isExpanded && node.children) {
|
||||
for (const child of node.children) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getAllExpandableIds(root: JsonTreeNode | null): string[] {
|
||||
if (!root) return [];
|
||||
const ids: string[] = [];
|
||||
|
||||
function walk(node: JsonTreeNode) {
|
||||
if (node.isExpandable) {
|
||||
ids.push(node.id);
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getExpandableIdsAboveDepth(root: JsonTreeNode | null, maxDepth: number): string[] {
|
||||
if (!root) return [];
|
||||
const ids: string[] = [];
|
||||
|
||||
function walk(node: JsonTreeNode) {
|
||||
if (node.isExpandable && node.depth >= maxDepth) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function isJsonParseable(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') return false;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < 2) return false;
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user