ir-studio / src /components /FileExplorerModal.tsx
Natnael B. Haile
Cursor
feat: complete IR Studio with Git LFS tracking for all binary assets and NFCorpus index
fbad523
Raw History Blame Contribute Delete
66.9 kB
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { Folder, FolderOpen, FileText, FileCode, Upload, Copy, Check, X, Trash2, CheckCircle2, AlertCircle, Maximize2, Minimize2, Search, WrapText, AlignLeft, Database, ChevronDown, ChevronUp, ChevronRight, ChevronsUpDown, Sparkles, Target, Activity, } from 'lucide-react'; import type { CorpusDataset, VirtualFile } from '../types/corpus'; import { validateAndParseCorpus, getSampleJsonlString, getSampleTsvString, MAX_CORPUS_DOCUMENTS, } from '../utils/corpusValidator'; interface FileExplorerModalProps { isOpen: boolean; onClose: () => void; datasets: CorpusDataset[]; activeDatasetId: string; onSelectDataset: (id: string) => void; onSaveCustomDataset: (dataset: CorpusDataset) => void; onDeleteCustomDataset: (id: string) => void; } // Token type for JSON syntax highlighting interface JsonToken { type: 'key' | 'string' | 'number' | 'literal' | 'punct' | 'text'; val: string; } // Fast tokenizer for a single line of JSON function tokenizeJsonLine(line: string): JsonToken[] { const tokens: JsonToken[] = []; const regex = /(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"\n])*\")(?=\s*:)|(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"\n])*\")|(\b(true|false|null)\b)|(-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)|([\{\}\[\],:])/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(line)) !== null) { if (match.index > lastIndex) { tokens.push({ type: 'text', val: line.slice(lastIndex, match.index) }); } if (match[1]) tokens.push({ type: 'key', val: match[1] }); else if (match[3]) tokens.push({ type: 'string', val: match[3] }); else if (match[5]) tokens.push({ type: 'literal', val: match[5] }); else if (match[7]) tokens.push({ type: 'number', val: match[7] }); else if (match[8]) tokens.push({ type: 'punct', val: match[8] }); lastIndex = regex.lastIndex; } if (lastIndex < line.length) { tokens.push({ type: 'text', val: line.slice(lastIndex) }); } return tokens; } // Format raw JSON or JSONL into pretty indented string function prettifyJsonContent(rawContent: string, isJsonl: boolean): string { if (isJsonl) { const lines = rawContent.trim().split(/\r?\n/); const prettyObjects: string[] = []; for (const line of lines) { if (!line.trim()) continue; try { const obj = JSON.parse(line); prettyObjects.push(JSON.stringify(obj, null, 2)); } catch { prettyObjects.push(line); } } return prettyObjects.join('\n\n'); } try { const parsed = JSON.parse(rawContent); return JSON.stringify(parsed, null, 2); } catch { return rawContent; } } // Split long passage text into clean, readable paragraphs function formatTextIntoParagraphs(text: string): string[] { if (!text) return []; // Split on existing double newlines if present if (text.includes('\n\n')) { return text.split(/\n\n+/).filter((p) => p.trim().length > 0); } // If text is one long paragraph with multiple sentences (e.g. medical abstract or MS MARCO passage), // split every 2-3 sentences to provide clean readability const sentences = text.match(/[^.!?]+[.!?]+(\s+|$)/g); if (!sentences || sentences.length <= 2) { return [text]; } const paragraphs: string[] = []; let current = ''; sentences.forEach((sentence, idx) => { current += sentence; if ((idx + 1) % 2 === 0 || idx === sentences.length - 1) { paragraphs.push(current.trim()); current = ''; } }); if (current.trim()) { paragraphs.push(current.trim()); } return paragraphs; } export const FileExplorerModal: React.FC<FileExplorerModalProps> = ({ isOpen, onClose, datasets, activeDatasetId, onSelectDataset, onSaveCustomDataset, onDeleteCustomDataset, }) => { const [activeTab, setActiveTab] = useState<'browse' | 'upload'>('browse'); const [isFullscreen, setIsFullscreen] = useState<boolean>(false); // Browse tab state const [selectedFilePath, setSelectedFilePath] = useState<string>(() => { const active = datasets.find((d) => d.id === activeDatasetId) || datasets[0]; return active?.files[0]?.path || '/collections/msmarco/collection_jsonl/docs00.json'; }); const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({ '/collections/msmarco': true, '/collections/msmarco/collection_jsonl': true, '/collections/nfcorpus': true, '/collections/nfcorpus/qrels': true, '/uploads/custom': true, }); const [viewMode, setViewMode] = useState<'raw' | 'parsed'>('raw'); const [formatJson, setFormatJson] = useState<boolean>(true); const [wrapLines, setWrapLines] = useState<boolean>(false); const [copied, setCopied] = useState(false); const [tableSearch, setTableSearch] = useState<string>(''); const [expandedRowDocId, setExpandedRowDocId] = useState<string | null>(null); // Fast Hover Tooltip Popover state const [tooltip, setTooltip] = useState<{ docId: string; field: 'title' | 'content'; text: string; top: number; left: number; } | null>(null); const [tooltipCopied, setTooltipCopied] = useState(false); const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Upload tab state const [customName, setCustomName] = useState('My Custom Corpus'); const [customContent, setCustomContent] = useState(''); const [customFormat, setCustomFormat] = useState<'auto' | 'jsonl' | 'tsv'>('auto'); const [customQuery, setCustomQuery] = useState(''); const [selectedGoldDocIds, setSelectedGoldDocIds] = useState<string[]>([]); const [goldRelevanceGrade, setGoldRelevanceGrade] = useState<number>(2); const [isReadingFile, setIsReadingFile] = useState<boolean>(false); // Upload validation const validation = customContent.trim() ? validateAndParseCorpus( customContent, customFormat === 'auto' ? undefined : customFormat ) : null; // Auto-select first document as ground truth target when valid documents are parsed useEffect(() => { if (validation?.isValid && validation.parsedDocuments.length > 0) { setSelectedGoldDocIds((prev) => { if (prev.length === 0 || !validation.parsedDocuments.some((d) => d.id === prev[0])) { return [validation.parsedDocuments[0].id]; } return prev; }); } }, [validation?.isValid, validation?.parsedDocuments]); const isUploadValid = Boolean( validation && validation.isValid && validation.parsedDocuments.length > 0 && customQuery.trim().length > 0 && selectedGoldDocIds.length > 0 ); const handleSaveUpload = useCallback(() => { if (!validation || !validation.isValid || validation.parsedDocuments.length === 0) return; if (!customQuery.trim() || selectedGoldDocIds.length === 0) return; const id = `custom-${Date.now().toString(36)}`; const format = customFormat === 'auto' ? (customContent.trim().startsWith('{') ? 'jsonl' : 'tsv') : customFormat; const virtualFile: VirtualFile = { path: `/uploads/custom/${id}.${format}`, name: `${customName.toLowerCase().replace(/\s+/g, '_')}.${format}`, type: format, sizeBytes: new TextEncoder().encode(customContent).length, lineCount: customContent.trim().split(/\r?\n/).length, datasetId: id, rawContent: customContent, }; const queryId = `q-${id}-1`; const cleanQuery = customQuery.trim(); const goldSet = new Set(selectedGoldDocIds); const newDataset: CorpusDataset = { id, name: customName.trim() || 'Custom Uploaded Corpus', description: `User corpus (${validation.documentCount} documents). Evaluation query: "${cleanQuery}"`, format, documentCount: validation.documentCount, documents: validation.parsedDocuments.map((d) => ({ ...d, goldRelevance: goldSet.has(d.id) ? goldRelevanceGrade : 0, })), queries: [ { id: queryId, text: cleanQuery, datasetId: id, relevantDocIds: selectedGoldDocIds, }, ], qrels: selectedGoldDocIds.map((docId) => ({ queryId, iteration: '0', docId, relevance: goldRelevanceGrade, })), files: [virtualFile], isCustom: true, createdAt: new Date().toISOString(), }; onSaveCustomDataset(newDataset); onSelectDataset(id); setSelectedFilePath(virtualFile.path); setActiveTab('browse'); setCustomContent(''); setCustomQuery(''); setSelectedGoldDocIds([]); onClose(); }, [ validation, customFormat, customContent, customName, customQuery, selectedGoldDocIds, goldRelevanceGrade, onSaveCustomDataset, onSelectDataset, onClose, ]); // Clean up tooltip timeout useEffect(() => { return () => { if (tooltipTimeoutRef.current) clearTimeout(tooltipTimeoutRef.current); }; }, []); // Collect all virtual files const allFiles: VirtualFile[] = datasets.flatMap((d) => d.files || []); const currentFile = allFiles.find((f) => f.path === selectedFilePath) || allFiles[0]; const currentDataset = datasets.find((d) => d.id === currentFile?.datasetId) || datasets.find((d) => d.id === activeDatasetId) || datasets[0]; const activeDataset = datasets.find((d) => d.id === activeDatasetId) || datasets[0]; // Group files by directory const filesByDir: Record<string, VirtualFile[]> = {}; allFiles.forEach((file) => { if (!file || !file.path) return; const dir = file.path.substring(0, file.path.lastIndexOf('/')) || '/'; if (!filesByDir[dir]) { filesByDir[dir] = []; } filesByDir[dir].push(file); }); const toggleFolder = (dir: string) => { setExpandedFolders((prev) => ({ ...prev, [dir]: !prev[dir] })); }; const isAllExpanded = useMemo(() => { const dirKeys = Object.keys(filesByDir); if (dirKeys.length === 0) return true; return dirKeys.every((dir) => expandedFolders[dir] !== false); }, [filesByDir, expandedFolders]); const toggleAllFolders = () => { const nextState: Record<string, boolean> = {}; const targetState = !isAllExpanded; Object.keys(filesByDir).forEach((dir) => { nextState[dir] = targetState; }); setExpandedFolders(nextState); }; const handleCopyRaw = () => { if (!currentFile) return; navigator.clipboard.writeText(currentFile.rawContent); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const handleCopyTooltip = (text: string) => { navigator.clipboard.writeText(text); setTooltipCopied(true); setTimeout(() => setTooltipCopied(false), 1500); }; // Hover Tooltip Triggers const handleCellMouseEnter = ( docId: string, field: 'title' | 'content', text: string, e: React.MouseEvent<HTMLElement> ) => { if (tooltipTimeoutRef.current) clearTimeout(tooltipTimeoutRef.current); const rect = e.currentTarget.getBoundingClientRect(); const tooltipWidth = 460; const tooltipHeight = 260; let left = rect.left; if (left + tooltipWidth > window.innerWidth - 16) { left = window.innerWidth - tooltipWidth - 16; } if (left < 16) left = 16; let top = rect.bottom + 8; if (top + tooltipHeight > window.innerHeight - 16) { top = Math.max(16, rect.top - tooltipHeight - 8); } setTooltip({ docId, field, text, top, left, }); }; const handleCellMouseLeave = () => { if (tooltipTimeoutRef.current) clearTimeout(tooltipTimeoutRef.current); tooltipTimeoutRef.current = setTimeout(() => { setTooltip(null); }, 180); }; const cancelTooltipHide = () => { if (tooltipTimeoutRef.current) clearTimeout(tooltipTimeoutRef.current); }; // Active corpus switch handler const handleSwitchActiveCorpus = (newId: string) => { onSelectDataset(newId); const targetDataset = datasets.find((d) => d.id === newId); if (targetDataset && targetDataset.files && targetDataset.files.length > 0) { setSelectedFilePath(targetDataset.files[0].path); // Auto expand folder const dir = targetDataset.files[0].path.substring(0, targetDataset.files[0].path.lastIndexOf('/')); setExpandedFolders((prev) => ({ ...prev, [dir]: true })); } }; // File type detection const isJsonFile = currentFile ? currentFile.type === 'jsonl' || (currentFile.type as string) === 'json' || (currentFile.name && currentFile.name.endsWith('.json')) || (currentFile.name && currentFile.name.endsWith('.jsonl')) || (currentFile.rawContent && currentFile.rawContent.trim().startsWith('{')) : false; const isJsonlFormat = currentFile ? currentFile.type === 'jsonl' || Boolean(currentFile.name && currentFile.name.endsWith('.jsonl')) : false; // Compute displayed lines for Raw / JSON view const formattedContent = useMemo(() => { if (!currentFile || !currentFile.rawContent) return ''; if (isJsonFile && formatJson) { return prettifyJsonContent(currentFile.rawContent, isJsonlFormat); } return currentFile.rawContent; }, [currentFile, isJsonFile, formatJson, isJsonlFormat]); const displayedLines = useMemo(() => { return (formattedContent || '').split(/\r?\n/); }, [formattedContent]); // Filtered documents for Schema Table view const filteredDocuments = useMemo(() => { if (!currentDataset || !currentDataset.documents) return []; if (!tableSearch.trim()) return currentDataset.documents; const q = tableSearch.toLowerCase(); return currentDataset.documents.filter( (d) => (d.id && d.id.toLowerCase().includes(q)) || (d.title && d.title.toLowerCase().includes(q)) || (d.text && d.text.toLowerCase().includes(q)) ); }, [currentDataset, tableSearch]); const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) { setIsReadingFile(true); const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result; if (typeof text === 'string') { setCustomContent(text); setCustomName(file.name.replace(/\.[^/.]+$/, '')); } setIsReadingFile(false); }; reader.onerror = () => setIsReadingFile(false); reader.readAsText(file); } }; const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) { setIsReadingFile(true); const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result; if (typeof text === 'string') { setCustomContent(text); setCustomName(file.name.replace(/\.[^/.]+$/, '')); } setIsReadingFile(false); }; reader.onerror = () => setIsReadingFile(false); reader.readAsText(file); } }; if (!isOpen) return null; return ( <div className={`fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center ${ isFullscreen ? 'p-0' : 'p-3 sm:p-5' }`} > <div className={`bg-zinc-950 border border-zinc-800 flex flex-col shadow-2xl overflow-hidden font-sans ${ isFullscreen ? 'w-screen h-screen rounded-none border-none' : 'w-full max-w-6xl h-[86vh] max-h-[880px] rounded-xl' }`} > {/* Modal Top Bar */} <div className="flex items-center justify-between px-4 py-2.5 border-b border-zinc-800 bg-zinc-900/70 shrink-0"> <div className="flex items-center space-x-2.5"> <div className="w-6 h-6 rounded bg-zinc-800 border border-zinc-700/60 flex items-center justify-center text-xs"> 📁 </div> <div className="flex items-center space-x-2"> <span className="text-sm font-semibold text-zinc-100"> Virtual File System </span> <span className="text-zinc-600">/</span> <span className="text-xs text-zinc-400 font-mono hidden sm:inline"> Raw Collections & Schema Inspector </span> </div> </div> <div className="flex items-center space-x-2.5"> {/* View switcher */} <div className="flex bg-zinc-900 p-0.5 rounded-lg border border-zinc-800 text-xs"> <button onClick={() => setActiveTab('browse')} className={`px-3 py-1 rounded-md transition font-medium ${ activeTab === 'browse' ? 'bg-zinc-800 text-white shadow-xs border border-zinc-700/60' : 'text-zinc-400 hover:text-zinc-200' }`} > Browse Files </button> <button onClick={() => setActiveTab('upload')} className={`px-3 py-1 rounded-md transition flex items-center space-x-1.5 font-medium ${ activeTab === 'upload' ? 'bg-zinc-800 text-white shadow-xs border border-zinc-700/60' : 'text-zinc-400 hover:text-zinc-200' }`} > <Upload className="w-3.5 h-3.5" /> <span>Upload Corpus</span> </button> </div> {/* Fullscreen Toggle */} <button onClick={() => setIsFullscreen(!isFullscreen)} className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-800 transition border border-transparent hover:border-zinc-700" title={isFullscreen ? 'Exit Fullscreen' : 'Expand Fullscreen'} > {isFullscreen ? ( <Minimize2 className="w-4 h-4" /> ) : ( <Maximize2 className="w-4 h-4" /> )} </button> {/* Close Button */} <button onClick={onClose} className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-800 transition border border-transparent hover:border-zinc-700" title="Close modal" > <X className="w-4 h-4" /> </button> </div> </div> {/* Modal Content */} {activeTab === 'browse' ? ( <div className="flex-1 flex overflow-hidden"> {/* Left Sidebar: Virtual File Tree & Active Corpus Switcher */} <div className="w-64 sm:w-72 border-r border-zinc-800 bg-zinc-950 flex flex-col text-xs shrink-0 select-none"> <div className="px-3 py-2 border-b border-zinc-850 font-mono text-zinc-400 flex items-center justify-between text-[11px] bg-zinc-900/40"> <span className="flex items-center gap-1.5 font-semibold text-zinc-300"> <Folder className="w-3.5 h-3.5 text-amber-400/80" /> Virtual Workspace </span> <div className="flex items-center gap-2"> <span className="text-[10px] text-zinc-500 font-mono">{allFiles.length} files</span> <button onClick={toggleAllFolders} className="p-1 rounded hover:bg-zinc-800/80 text-zinc-400 hover:text-zinc-200 transition cursor-pointer" title={isAllExpanded ? "Collapse all folders" : "Expand all folders"} > <ChevronsUpDown className="w-3 h-3" /> </button> </div> </div> {/* File Tree List */} <div className="flex-1 overflow-y-auto p-2 space-y-1 font-mono"> {Object.entries(filesByDir).map(([dir, files]) => { const isExpanded = expandedFolders[dir] ?? true; const isCustomDir = dir.includes('custom'); const isDirActiveCorpus = files.some( (f) => f.datasetId === activeDatasetId ); return ( <div key={dir} className="space-y-0.5"> <button onClick={() => toggleFolder(dir)} className={`w-full group flex items-center justify-between px-2 py-1 rounded-md text-left transition cursor-pointer ${ isDirActiveCorpus ? 'text-zinc-200 bg-zinc-900/50 hover:bg-zinc-900 font-medium' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50' }`} title={isExpanded ? 'Click to collapse folder' : 'Click to expand folder'} > <div className="flex items-center space-x-1.5 truncate min-w-0"> <ChevronRight className={`w-3 h-3 text-zinc-500 group-hover:text-zinc-300 transition-transform duration-150 shrink-0 ${ isExpanded ? 'rotate-90 text-zinc-300' : '' }`} /> {isExpanded ? ( <FolderOpen className="w-3.5 h-3.5 text-amber-400/90 shrink-0" /> ) : ( <Folder className="w-3.5 h-3.5 text-amber-400/70 shrink-0" /> )} <span className="truncate text-[11px] font-mono tracking-tight">{dir}</span> </div> <div className="flex items-center space-x-1.5 shrink-0 ml-1.5"> <span className="text-[10px] text-zinc-500 font-mono px-1 py-0.2 rounded bg-zinc-900/80 border border-zinc-850"> {files.length} </span> {isDirActiveCorpus && ( <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0" title="Contains active mounted corpus" /> )} </div> </button> {isExpanded && ( <div className="ml-2.5 pl-2 border-l border-zinc-850 space-y-0.5"> {files.map((file) => { const isSelected = selectedFilePath === file.path; const isDatasetActive = file.datasetId === activeDatasetId; return ( <div key={file.path} className={`group flex items-center justify-between px-2 py-1 rounded-md cursor-pointer transition ${ isSelected ? 'bg-zinc-800 text-zinc-100 font-semibold border border-zinc-700/60 shadow-2xs' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/60' }`} onClick={() => setSelectedFilePath(file.path)} > <div className="flex items-center space-x-1.5 truncate min-w-0"> {file.type === 'jsonl' || (file.type as string) === 'json' || file.name.endsWith('.json') ? ( <FileCode className={`w-3.5 h-3.5 shrink-0 ${isSelected ? 'text-sky-400' : 'text-zinc-500'}`} /> ) : ( <FileText className={`w-3.5 h-3.5 shrink-0 ${isSelected ? 'text-amber-400' : 'text-zinc-500'}`} /> )} <span className="truncate text-[11px]">{file.name}</span> </div> <div className="flex items-center space-x-1.5 shrink-0 ml-1.5"> {isDatasetActive ? ( <span className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-emerald-950 text-emerald-300 border border-emerald-800/80"> active </span> ) : ( <button onClick={(e) => { e.stopPropagation(); handleSwitchActiveCorpus(file.datasetId); }} className="opacity-0 group-hover:opacity-100 text-[9px] font-mono px-1 py-0.2 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition" title="Mount this dataset as active" > mount </button> )} {isCustomDir && ( <button onClick={(e) => { e.stopPropagation(); onDeleteCustomDataset(file.datasetId); }} className="opacity-0 group-hover:opacity-100 p-0.5 text-zinc-500 hover:text-rose-400 transition" title="Delete custom dataset" > <Trash2 className="w-3 h-3" /> </button> )} </div> </div> ); })} </div> )} </div> ); })} </div> {/* Active Corpus Switcher (Compact, Refined, Modern) */} <div className="p-2.5 border-t border-zinc-800 bg-zinc-900/40 flex flex-col gap-2 shrink-0"> <div className="flex items-center justify-between text-[11px]"> <div className="flex items-center gap-1.5 font-mono text-zinc-400 font-semibold tracking-wider text-[10px] uppercase"> <Database className="w-3.5 h-3.5 text-emerald-400 shrink-0" /> <span>Mounted Corpus</span> </div> <span className="inline-flex items-center gap-1 px-1.5 py-0.2 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[9px] font-mono font-medium"> <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> live </span> </div> {/* Styled Dropdown with Custom Chevron */} <div className="relative"> <select value={activeDatasetId} onChange={(e) => handleSwitchActiveCorpus(e.target.value)} className="w-full appearance-none bg-zinc-900/90 border border-zinc-800 hover:border-zinc-700 focus:border-zinc-600 rounded-md pl-2.5 pr-7 py-1.5 text-xs text-zinc-100 font-medium cursor-pointer transition shadow-2xs focus:outline-none" title="Switch active mounted corpus" > {datasets.map((d) => ( <option key={d.id} value={d.id} className="bg-zinc-900 text-zinc-100"> {d.name} ({d.documentCount} docs) </option> ))} </select> <ChevronDown className="w-3.5 h-3.5 text-zinc-400 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" /> </div> {/* Compact Inline Stats Ribbon */} <div className="flex items-center justify-between text-[10px] font-mono px-2 py-1 rounded-md bg-zinc-950/70 border border-zinc-850 text-zinc-400"> <span className="flex items-center gap-1"> <span className="text-zinc-500 text-[9px]">DOCS:</span> <span className="text-zinc-200 font-semibold">{activeDataset?.documentCount}</span> </span> <span className="text-zinc-650">•</span> <span className="flex items-center gap-1"> <span className="text-zinc-500 text-[9px]">FMT:</span> <span className="text-zinc-200 font-semibold uppercase">{activeDataset?.format}</span> </span> <span className="text-zinc-650">•</span> <span className="flex items-center gap-1"> <span className="text-zinc-500 text-[9px]">QRY:</span> <span className="text-zinc-200 font-semibold">{activeDataset?.queries?.length ?? 1}</span> </span> </div> </div> </div> {/* Right Column: File Content & Schema Inspector */} <div className="flex-1 flex flex-col bg-zinc-950 overflow-hidden"> {currentFile ? ( <> {/* File Metadata & Toolbar */} <div className="px-4 py-2.5 border-b border-zinc-800 bg-zinc-900/40 flex flex-wrap items-center justify-between gap-2 text-xs shrink-0"> <div className="flex items-center space-x-3 text-zinc-400 font-mono"> <span className="text-zinc-100 font-semibold flex items-center gap-1.5"> <FileCode className="w-3.5 h-3.5 text-sky-400" /> {currentFile.path} </span> <span>•</span> <span>{(currentFile.sizeBytes / 1024).toFixed(1)} KB</span> <span>•</span> <span>{currentFile.lineCount} lines</span> </div> <div className="flex items-center space-x-2"> {/* View mode switcher */} <div className="flex bg-zinc-900 p-0.5 rounded-md border border-zinc-800 text-[11px]"> <button onClick={() => setViewMode('raw')} className={`px-2.5 py-1 rounded transition font-medium ${ viewMode === 'raw' ? 'bg-zinc-800 text-zinc-100 shadow-xs' : 'text-zinc-400 hover:text-zinc-200' }`} > Code & Raw </button> <button onClick={() => setViewMode('parsed')} className={`px-2.5 py-1 rounded transition font-medium ${ viewMode === 'parsed' ? 'bg-zinc-800 text-zinc-100 shadow-xs' : 'text-zinc-400 hover:text-zinc-200' }`} > Schema Table </button> </div> {/* Raw Mode Controls: JSON Formatter & Line Wrap */} {viewMode === 'raw' && ( <> {isJsonFile && ( <button onClick={() => setFormatJson(!formatJson)} className={`px-2 py-1 rounded text-[11px] font-mono border transition flex items-center gap-1 ${ formatJson ? 'bg-sky-950/80 text-sky-300 border-sky-700/80 shadow-xs font-semibold' : 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:text-zinc-200' }`} title="Toggle pretty-printed JSON formatting" > <Sparkles className="w-3 h-3" /> <span>{formatJson ? 'Pretty JSON' : 'Compact JSON'}</span> </button> )} <button onClick={() => setWrapLines(!wrapLines)} className={`px-2 py-1 rounded text-[11px] font-mono border transition flex items-center gap-1 ${ wrapLines ? 'bg-zinc-800 text-zinc-100 border-zinc-700' : 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:text-zinc-200' }`} title={wrapLines ? 'Disable line wrap (enable horizontal scroll)' : 'Enable line wrap'} > {wrapLines ? <WrapText className="w-3 h-3" /> : <AlignLeft className="w-3 h-3" />} <span>{wrapLines ? 'Wrap' : 'Scroll-X'}</span> </button> </> )} {/* Copy File Button */} <button onClick={handleCopyRaw} className="px-2.5 py-1 rounded bg-zinc-900 hover:bg-zinc-850 text-zinc-300 border border-zinc-800 text-xs flex items-center space-x-1.5 transition" title="Copy entire raw file to clipboard" > {copied ? ( <> <Check className="w-3 h-3 text-emerald-400" /> <span className="text-emerald-400 font-medium">Copied</span> </> ) : ( <> <Copy className="w-3 h-3 text-zinc-400" /> <span>Copy</span> </> )} </button> {/* Mount Button if file belongs to another dataset */} {currentDataset && currentDataset.id !== activeDatasetId && ( <button onClick={() => handleSwitchActiveCorpus(currentDataset.id)} className="px-2.5 py-1 rounded bg-emerald-950 hover:bg-emerald-900 text-emerald-300 text-xs font-medium border border-emerald-700/80 transition flex items-center gap-1 shadow-xs" > <Database className="w-3 h-3" /> <span>Mount Corpus</span> </button> )} </div> </div> {/* Viewer Content Area */} <div className="flex-1 overflow-hidden flex flex-col"> {viewMode === 'raw' ? ( /* Code / Raw Lines Viewer */ <div className="flex-1 overflow-auto p-3 font-mono text-xs bg-zinc-950 select-text"> <table className="w-full border-collapse"> <tbody> {displayedLines.map((line, idx) => { const tokens = isJsonFile && formatJson ? tokenizeJsonLine(line) : null; return ( <tr key={idx} className="hover:bg-zinc-900/40 group"> {/* Line Number Gutter */} <td className="py-0.5 px-3 select-none text-zinc-600 group-hover:text-zinc-400 text-right w-12 border-r border-zinc-850 text-[11px] align-top"> {idx + 1} </td> {/* Code Content */} <td className={`py-0.5 px-3 leading-relaxed ${ wrapLines ? 'whitespace-pre-wrap break-words' : 'whitespace-pre' }`} > {tokens ? ( <span> {tokens.map((token, tIdx) => { if (token.type === 'key') { return ( <span key={tIdx} className="text-sky-300 font-semibold"> {token.val} </span> ); } if (token.type === 'string') { return ( <span key={tIdx} className="text-emerald-300/90"> {token.val} </span> ); } if (token.type === 'number') { return ( <span key={tIdx} className="text-amber-300 font-medium"> {token.val} </span> ); } if (token.type === 'literal') { return ( <span key={tIdx} className="text-purple-300 font-medium"> {token.val} </span> ); } if (token.type === 'punct') { return ( <span key={tIdx} className="text-zinc-500"> {token.val} </span> ); } return ( <span key={tIdx} className="text-zinc-300"> {token.val} </span> ); })} </span> ) : ( <span className="text-zinc-300">{line}</span> )} </td> </tr> ); })} </tbody> </table> </div> ) : ( /* Enhanced Schema Table with Search, Expandable Rows, and Hover Tooltips */ <div className="flex-1 flex flex-col overflow-hidden"> {/* Table Search & Count Bar */} <div className="p-3 border-b border-zinc-850 bg-zinc-900/30 flex items-center justify-between gap-3 text-xs shrink-0"> <div className="relative flex-1 max-w-sm"> <Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-zinc-500" /> <input type="text" value={tableSearch} onChange={(e) => setTableSearch(e.target.value)} placeholder="Search passages by docid, title, or keywords..." className="w-full bg-zinc-950 border border-zinc-800 rounded-md pl-8 pr-3 py-1.5 text-xs text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-500 font-sans" /> {tableSearch && ( <button onClick={() => setTableSearch('')} className="absolute right-2.5 top-2.5 text-zinc-500 hover:text-zinc-200" > <X className="w-3.5 h-3.5" /> </button> )} </div> <div className="flex items-center space-x-3 text-zinc-400 font-mono text-[11px]"> <span> Showing {filteredDocuments.length} of {currentDataset?.documents.length || 0} passages </span> <span>•</span> <span className="text-zinc-500 hidden sm:inline"> Hover cell for full formatted passage </span> </div> </div> {/* Horizontally Scrollable Table Container */} <div className="flex-1 overflow-auto"> <table className="w-full text-left text-xs font-sans border-collapse min-w-[850px]"> <thead> <tr className="sticky top-0 bg-zinc-900/95 backdrop-blur-xs z-10 border-b border-zinc-800 text-zinc-400 font-mono text-[11px]"> <th className="py-2.5 px-3 w-12 text-center">#</th> <th className="py-2.5 px-3 w-32 min-w-[120px]">docid</th> <th className="py-2.5 px-3 w-64 min-w-[240px]">title</th> <th className="py-2.5 px-3 min-w-[460px]">passage content</th> <th className="py-2.5 px-3 w-24 text-right pr-4">inspect</th> </tr> </thead> <tbody className="divide-y divide-zinc-850"> {filteredDocuments.map((doc, idx) => { const isExpanded = expandedRowDocId === doc.id; return ( <React.Fragment key={doc.id}> <tr className="hover:bg-zinc-900/50 transition group"> {/* Row Number */} <td className="py-3 px-3 font-mono text-zinc-500 text-center select-none text-[11px] align-top"> {idx + 1} </td> {/* Doc ID */} <td className="py-3 px-3 font-mono text-sky-300 font-semibold whitespace-nowrap align-top"> <div className="flex items-center space-x-1.5"> <span>{doc.id}</span> {doc.goldRelevance !== undefined && doc.goldRelevance > 0 && ( <span className={`text-[9px] px-1 rounded ${ doc.goldRelevance === 2 ? 'bg-emerald-950 text-emerald-300 border border-emerald-800' : 'bg-sky-950 text-sky-300 border border-sky-800' }`} > rel={doc.goldRelevance} </span> )} </div> </td> {/* Document Title (with Hover Tooltip) */} <td className="py-3 px-3 text-zinc-200 font-medium align-top cursor-pointer group-hover:text-white" onMouseEnter={(e) => handleCellMouseEnter(doc.id, 'title', doc.title || doc.id, e) } onMouseLeave={handleCellMouseLeave} > <div className="line-clamp-2 leading-relaxed"> {doc.title || '-'} </div> </td> {/* Passage Content (with Hover Tooltip and Full Readability) */} <td className="py-3 px-3 text-zinc-400 align-top cursor-pointer group-hover:text-zinc-200" onMouseEnter={(e) => handleCellMouseEnter(doc.id, 'content', doc.text, e) } onMouseLeave={handleCellMouseLeave} > <div className="line-clamp-2 leading-relaxed"> {doc.text} </div> </td> {/* Expand/Collapse Inline Row */} <td className="py-3 px-3 text-right pr-4 align-top"> <button onClick={() => setExpandedRowDocId(isExpanded ? null : doc.id) } className="text-[11px] font-mono text-zinc-400 hover:text-zinc-100 px-2 py-0.5 rounded bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 transition inline-flex items-center gap-1" > <span>{isExpanded ? 'close' : 'view'}</span> {isExpanded ? ( <ChevronUp className="w-3 h-3" /> ) : ( <ChevronDown className="w-3 h-3" /> )} </button> </td> </tr> {/* Expanded Row Drawer */} {isExpanded && ( <tr className="bg-zinc-900/60"> <td colSpan={5} className="p-4 border-b border-zinc-800"> <div className="space-y-3 bg-zinc-950 p-4 rounded-lg border border-zinc-800 text-xs font-sans"> <div className="flex items-center justify-between border-b border-zinc-850 pb-2"> <div className="flex items-center space-x-2"> <span className="font-mono text-sky-300 font-semibold text-xs"> [{doc.id}] </span> <h4 className="font-semibold text-zinc-100"> {doc.title || 'Untitled Passage'} </h4> </div> <button onClick={() => handleCopyTooltip(doc.text)} className="flex items-center space-x-1 font-mono text-[11px] text-zinc-400 hover:text-zinc-100 px-2 py-1 rounded bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 transition" > <Copy className="w-3 h-3" /> <span>Copy text</span> </button> </div> <div className="space-y-2 text-zinc-300 leading-relaxed"> {formatTextIntoParagraphs(doc.text).map((p, pIdx) => ( <p key={pIdx}>{p}</p> ))} </div> </div> </td> </tr> )} </React.Fragment> ); })} </tbody> </table> </div> </div> )} </div> </> ) : ( <div className="flex-1 flex items-center justify-center text-zinc-500 text-xs font-mono"> Select a file from the sidebar. </div> )} </div> </div> ) : ( /* Tab 2: Upload Micro-Corpus */ <div className="flex-1 overflow-y-auto p-6 space-y-5 text-xs font-sans"> <div> <h3 className="font-semibold text-sm text-zinc-100 flex items-center gap-2"> <Upload className="w-4 h-4 text-emerald-400" /> Upload Micro-Corpus to Virtual Workspace </h3> <p className="text-zinc-400 mt-1"> Paste or drop a JSONL (<code className="text-zinc-300 font-mono">id, contents</code>) or TSV (<code className="text-zinc-300 font-mono">id \t text</code>) file. Corpora are capped at {MAX_CORPUS_DOCUMENTS} documents for instantaneous indexing and benchmark evaluation. </p> </div> {/* Template Presets */} <div className="flex items-center space-x-2"> <span className="text-zinc-500 text-[11px] font-mono">Format Templates:</span> <button type="button" onClick={() => { setCustomContent(getSampleJsonlString()); setCustomFormat('jsonl'); setCustomName('Sample Inverted Index Corpus'); setCustomQuery('BM25 ranking term saturation and length normalization'); setSelectedGoldDocIds(['doc-002']); }} className="px-2.5 py-1 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-200 border border-zinc-800 hover:border-zinc-700 text-[11px] font-mono transition flex items-center gap-1.5" > <FileCode className="w-3 h-3 text-sky-400" /> <span>+ JSONL Sample</span> </button> <button type="button" onClick={() => { setCustomContent(getSampleTsvString()); setCustomFormat('tsv'); setCustomName('Sample Evaluation Corpus'); setCustomQuery('TREC evaluation metric ndcg and mrr calculation'); setSelectedGoldDocIds(['doc-004']); }} className="px-2.5 py-1 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-200 border border-zinc-800 hover:border-zinc-700 text-[11px] font-mono transition flex items-center gap-1.5" > <FileText className="w-3 h-3 text-amber-400" /> <span>+ TSV Sample</span> </button> </div> {/* Inputs: Corpus Name & Format */} <div className="grid grid-cols-1 md:grid-cols-3 gap-3.5"> <div className="md:col-span-2"> <label className="block text-zinc-400 mb-1 font-medium">Corpus Name</label> <input type="text" value={customName} onChange={(e) => setCustomName(e.target.value)} placeholder="e.g. Biomed COVID Subcorpus" className="w-full bg-zinc-900 border border-zinc-800 focus:border-zinc-600 rounded-md px-3 py-1.5 text-zinc-100 focus:outline-none text-xs" /> </div> <div> <label className="block text-zinc-400 mb-1 font-medium">Format</label> <select value={customFormat} onChange={(e) => setCustomFormat(e.target.value as any)} className="w-full bg-zinc-900 border border-zinc-800 focus:border-zinc-600 rounded-md px-2.5 py-1.5 text-zinc-100 focus:outline-none text-xs font-mono" > <option value="auto">Auto-detect format</option> <option value="jsonl">JSONL (JSON Lines)</option> <option value="tsv">TSV (Tab-separated)</option> </select> </div> </div> {/* Dropzone & Textarea */} <div onDragOver={(e) => e.preventDefault()} onDrop={handleFileDrop} className="space-y-1.5"> <div className="flex items-center justify-between text-zinc-500 text-[11px] font-mono"> <span>Passage Data (Drop file or paste raw content):</span> <label className="cursor-pointer text-zinc-400 hover:text-zinc-200 underline font-sans"> browse local file <input type="file" accept=".jsonl,.tsv,.json,.txt" onChange={handleFileInput} className="hidden" /> </label> </div> {isReadingFile && ( <div className="flex items-center gap-2 text-xs font-mono text-sky-400 p-2 rounded bg-sky-950/40 border border-sky-800/40 animate-pulse"> <Activity className="w-3.5 h-3.5 animate-spin" /> <span>Reading and parsing corpus file from disk...</span> </div> )} <textarea rows={9} value={customContent} onChange={(e) => setCustomContent(e.target.value)} placeholder={'{"id": "doc1", "contents": "..."}\n{"id": "doc2", "contents": "..."}'} className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-3 font-mono text-xs text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 whitespace-pre" /> </div> {/* Validation Feedback */} {validation && ( <div className={`p-3.5 rounded-lg border font-mono text-xs flex items-center space-x-2.5 ${ validation.isValid ? 'border-emerald-800/60 bg-emerald-950/30 text-emerald-300' : 'border-rose-800/60 bg-rose-950/30 text-rose-300' }`} > {validation.isValid ? ( <> <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" /> <span> Validated: {validation.documentCount} document{validation.documentCount === 1 ? '' : 's'} ready for indexing. </span> </> ) : ( <> <AlertCircle className="w-4 h-4 text-rose-400 shrink-0" /> <span> Failed validation: {validation.errors.join(', ')} </span> </> )} </div> )} {/* Step 2: Evaluation Query & Ground Truth Target (Required) */} {validation?.isValid && ( <div className="p-4 rounded-xl border border-sky-900/40 bg-sky-950/20 space-y-3.5 shadow-xs"> <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex items-center gap-2"> <Target className="w-4 h-4 text-sky-400" /> <span className="text-xs font-semibold text-zinc-100"> Evaluation Query & Relevant Ground Truth Target </span> <span className="text-[10px] px-1.5 py-0.5 rounded bg-sky-500/20 text-sky-300 font-mono font-medium border border-sky-500/30"> Required </span> </div> <span className="text-[11px] font-mono text-zinc-400"> Sets canonical test query & qrels for Arena evaluation </span> </div> {/* Query Input */} <div className="space-y-1"> <div className="flex items-center justify-between text-[11px]"> <label className="text-zinc-300 font-medium"> Evaluation Query (Default search query for this corpus): </label> {customQuery.trim() === '' ? ( <span className="text-amber-400 font-mono text-[10px] font-semibold"> * Required field </span> ) : ( <span className="text-emerald-400 font-mono text-[10px]"> ✓ {customQuery.trim().split(/\s+/).filter(Boolean).length} tokens </span> )} </div> <input type="text" value={customQuery} onChange={(e) => setCustomQuery(e.target.value)} placeholder="e.g. search query testing passage retrieval against this corpus..." className="w-full bg-zinc-900 border border-zinc-750 focus:border-sky-500 rounded-lg px-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-none font-medium" /> </div> {/* Ground Truth Passage Selection & Relevance Grade */} <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="md:col-span-2 space-y-1"> <div className="flex items-center justify-between text-[11px]"> <label className="text-zinc-300 font-medium"> Relevant Ground Truth Passage (Gold Target): </label> {selectedGoldDocIds.length === 0 ? ( <span className="text-amber-400 font-mono text-[10px] font-semibold"> * Select at least 1 passage </span> ) : ( <span className="text-emerald-400 font-mono text-[10px]"> ✓ Target assigned </span> )} </div> <select value={selectedGoldDocIds[0] || ''} onChange={(e) => { const val = e.target.value; if (val) setSelectedGoldDocIds([val]); }} className="w-full bg-zinc-900 border border-zinc-750 focus:border-sky-500 rounded-lg px-2.5 py-2 text-xs text-zinc-100 font-mono focus:outline-none cursor-pointer" > {validation.parsedDocuments.map((doc) => ( <option key={doc.id} value={doc.id}> [{doc.id}] {doc.title ? `${doc.title} - ` : ''} {doc.text.slice(0, 55)}... </option> ))} </select> </div> <div className="space-y-1"> <label className="block text-[11px] text-zinc-300 font-medium"> Relevance Grade: </label> <select value={goldRelevanceGrade} onChange={(e) => setGoldRelevanceGrade(Number(e.target.value))} className="w-full bg-zinc-900 border border-zinc-750 focus:border-sky-500 rounded-lg px-2.5 py-2 text-xs text-zinc-100 font-mono focus:outline-none cursor-pointer" > <option value={2}>rel = 2 (Highly Relevant / Gold)</option> <option value={1}>rel = 1 (Relevant / Pertinent)</option> </select> </div> </div> {/* Preview Selected Ground Truth Passage */} {selectedGoldDocIds.length > 0 && ( <div className="text-[11px] font-mono bg-zinc-900/90 border border-zinc-800 rounded-lg p-2.5 text-zinc-400"> <span className="text-sky-300 font-semibold"> Target Passage [{selectedGoldDocIds[0]}]:{' '} </span> <span className="text-zinc-200"> {validation.parsedDocuments.find((d) => d.id === selectedGoldDocIds[0])?.text.slice(0, 140)}... </span> </div> )} </div> )} {/* Submit Action */} <div className="flex flex-wrap items-center justify-between gap-3 pt-2.5 border-t border-zinc-850"> <div className="text-[11px] font-mono text-zinc-400"> {validation?.isValid && (!customQuery.trim() || selectedGoldDocIds.length === 0) ? ( <span className="text-amber-400 font-semibold"> * Provide an evaluation query and select a relevant ground-truth passage to mount corpus. </span> ) : isUploadValid ? ( <span className="text-emerald-400"> ✓ Corpus ready to mount with custom query and ground truth target. </span> ) : null} </div> <div className="flex items-center space-x-2.5"> <button type="button" onClick={() => setActiveTab('browse')} className="px-3.5 py-1.5 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-400 text-xs transition cursor-pointer" > Cancel </button> <button type="button" onClick={handleSaveUpload} disabled={!isUploadValid} className={`px-4 py-1.5 rounded-md text-xs font-semibold transition flex items-center gap-1.5 ${ isUploadValid ? 'bg-zinc-100 hover:bg-white text-zinc-900 shadow-sm cursor-pointer' : 'bg-zinc-900 text-zinc-600 cursor-not-allowed border border-zinc-800' }`} > <Check className="w-3.5 h-3.5" /> <span>Save & Mount Corpus</span> </button> </div> </div> </div> )} </div> {/* Fast Hover Tooltip Popover (Rendered at top level for clean clipping escape) */} {tooltip && ( <div style={{ top: `${tooltip.top}px`, left: `${tooltip.left}px` }} className="fixed z-50 w-[460px] max-w-[92vw] bg-zinc-900/98 backdrop-blur-md border border-zinc-750 shadow-2xl rounded-xl p-4 text-xs font-sans pointer-events-auto transition-opacity duration-150 animate-in fade-in zoom-in-95" onMouseEnter={cancelTooltipHide} onMouseLeave={handleCellMouseLeave} > {/* Header */} <div className="flex items-center justify-between pb-2.5 mb-2.5 border-b border-zinc-800"> <div className="flex items-center space-x-2"> <span className="font-mono text-[11px] px-2 py-0.5 rounded bg-zinc-800 text-sky-300 font-semibold border border-zinc-700/60"> [{tooltip.docId}] </span> <span className="text-[11px] font-semibold text-zinc-300 uppercase tracking-wider"> {tooltip.field === 'title' ? 'Document Title' : 'Passage Content'} </span> </div> <div className="flex items-center space-x-2"> <span className="text-[10px] font-mono text-zinc-400"> {tooltip.text.split(/\s+/).filter(Boolean).length} words </span> <button onClick={() => handleCopyTooltip(tooltip.text)} className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100 transition" title="Copy full text to clipboard" > {tooltipCopied ? ( <Check className="w-3.5 h-3.5 text-emerald-400" /> ) : ( <Copy className="w-3.5 h-3.5" /> )} </button> </div> </div> {/* Formatted Paragraphs with Clean Spacing */} <div className="max-h-64 overflow-y-auto pr-1 space-y-2.5 text-zinc-200 leading-relaxed text-xs font-normal select-text"> {formatTextIntoParagraphs(tooltip.text).map((para, i) => ( <p key={i} className="text-zinc-200"> {para} </p> ))} </div> </div> )} </div> ); };
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
Folder,
FolderOpen,
FileText,
FileCode,
Upload,
Copy,
Check,
X,
Trash2,
CheckCircle2,
AlertCircle,
Maximize2,
Minimize2,
Search,
WrapText,
AlignLeft,
Database,
ChevronDown,
ChevronUp,
ChevronRight,
ChevronsUpDown,
Sparkles,
Target,
Activity,
} from 'lucide-react';
import type { CorpusDataset, VirtualFile } from '../types/corpus';
import {
validateAndParseCorpus,
getSampleJsonlString,
getSampleTsvString,
MAX_CORPUS_DOCUMENTS,
} from '../utils/corpusValidator';
interface FileExplorerModalProps {
isOpen: boolean;
onClose: () => void;
datasets: CorpusDataset[];
activeDatasetId: string;
onSelectDataset: (id: string) => void;
onSaveCustomDataset: (dataset: CorpusDataset) => void;
onDeleteCustomDataset: (id: string) => void;
}
// Token type for JSON syntax highlighting
interface JsonToken {
type: 'key' | 'string' | 'number' | 'literal' | 'punct' | 'text';
val: string;
}
// Fast tokenizer for a single line of JSON
function tokenizeJsonLine(line: string): JsonToken[] {
const tokens: JsonToken[] = [];
const regex = /(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"\n])*\")(?=\s*:)|(\"(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\\"\n])*\")|(\b(true|false|null)\b)|(-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)|([\{\}\[\],:])/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
tokens.push({ type: 'text', val: line.slice(lastIndex, match.index) });
}
if (match[1]) tokens.push({ type: 'key', val: match[1] });
else if (match[3]) tokens.push({ type: 'string', val: match[3] });
else if (match[5]) tokens.push({ type: 'literal', val: match[5] });
else if (match[7]) tokens.push({ type: 'number', val: match[7] });
else if (match[8]) tokens.push({ type: 'punct', val: match[8] });
lastIndex = regex.lastIndex;
}
if (lastIndex < line.length) {
tokens.push({ type: 'text', val: line.slice(lastIndex) });
}
return tokens;
}
// Format raw JSON or JSONL into pretty indented string
function prettifyJsonContent(rawContent: string, isJsonl: boolean): string {
if (isJsonl) {
const lines = rawContent.trim().split(/\r?\n/);
const prettyObjects: string[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
prettyObjects.push(JSON.stringify(obj, null, 2));
} catch {
prettyObjects.push(line);
}
}
return prettyObjects.join('\n\n');
}
try {
const parsed = JSON.parse(rawContent);
return JSON.stringify(parsed, null, 2);
} catch {
return rawContent;
}
}