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:
Nguyễn Ngô Thượng
2026-04-01 17:30:28 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d9fdd39f99
commit 6180f388e8
7 changed files with 755 additions and 1 deletions
+70 -1
View File
@@ -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 && (