| |
| |
| |
| |
| |
|
|
| import React, { useState, useEffect, useRef, useMemo } from 'react'; |
| import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks'; |
|
|
| type ThemeMode = 'light' | 'dark' | null; |
|
|
| interface ThemeColors { |
| bg: string; |
| bgSecondary: string; |
| bgHover: string; |
| border: string; |
| text: string; |
| textSecondary: string; |
| accent: string; |
| consoleBg: string; |
| rowBorder: string; |
| errorBg: string; |
| errorText: string; |
| warnBg: string; |
| warnText: string; |
| } |
|
|
| export default function App() { |
| const [activeTab, setActiveTab] = useState<'console' | 'network'>('console'); |
| const { networkLogs, consoleLogs, connectedSessions } = useDevToolsData(); |
| const [selectedSessionId, setSelectedSessionId] = useState<string | null>( |
| null, |
| ); |
| const [importedLogs, setImportedLogs] = useState<{ |
| network: NetworkLog[]; |
| console: ConsoleLog[]; |
| } | null>(null); |
| const [importedSessionId, setImportedSessionId] = useState<string | null>( |
| null, |
| ); |
|
|
| |
| const [toastMessage, setToastMessage] = useState<string | null>(null); |
| const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
|
| const showToast = (msg: string) => { |
| setToastMessage(msg); |
| if (toastTimeoutRef.current) { |
| clearTimeout(toastTimeoutRef.current); |
| } |
| toastTimeoutRef.current = setTimeout(() => { |
| setToastMessage(null); |
| toastTimeoutRef.current = null; |
| }, 5000); |
| }; |
|
|
| |
| const [themeMode, setThemeMode] = useState<ThemeMode>(() => { |
| const saved = localStorage.getItem('devtools-theme'); |
| if (!saved) return null; |
| return saved as ThemeMode; |
| }); |
|
|
| const [systemIsDark, setSystemIsDark] = useState( |
| window.matchMedia('(prefers-color-scheme: dark)').matches, |
| ); |
|
|
| useEffect(() => { |
| const media = window.matchMedia('(prefers-color-scheme: dark)'); |
| const listener = (e: MediaQueryListEvent) => setSystemIsDark(e.matches); |
| media.addEventListener('change', listener); |
| return () => media.removeEventListener('change', listener); |
| }, []); |
|
|
| const isDark = themeMode === null ? systemIsDark : themeMode === 'dark'; |
|
|
| const t = useMemo( |
| () => ({ |
| bg: isDark ? '#202124' : '#ffffff', |
| bgSecondary: isDark ? '#292a2d' : '#f3f3f3', |
| bgHover: isDark ? '#35363a' : '#e8f0fe', |
| border: isDark ? '#3c4043' : '#ccc', |
| text: isDark ? '#e8eaed' : '#333', |
| textSecondary: isDark ? '#9aa0a6' : '#666', |
| accent: isDark ? '#8ab4f8' : '#1a73e8', |
| consoleBg: isDark ? '#1e1e1e' : '#fff', |
| rowBorder: isDark ? '#303134' : '#f0f0f0', |
| errorBg: isDark ? '#3c1e1e' : '#fff0f0', |
| errorText: isDark ? '#f28b82' : '#a80000', |
| warnBg: isDark ? '#302a10' : '#fff3cd', |
| warnText: isDark ? '#fdd663' : '#7a5d00', |
| }), |
| [isDark], |
| ); |
|
|
| const toggleTheme = () => { |
| const nextMode = isDark ? 'light' : 'dark'; |
| setThemeMode(nextMode); |
| localStorage.setItem('devtools-theme', nextMode); |
| }; |
|
|
| |
| const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (!file) return; |
|
|
| const reader = new FileReader(); |
| reader.onload = (event) => { |
| const content = event.target?.result as string; |
| try { |
| const networkMap = new Map<string, NetworkLog>(); |
| const consoleLogs: ConsoleLog[] = []; |
|
|
| content |
| .split('\n') |
| .filter((l) => l.trim()) |
| .forEach((l) => { |
| const parsed = JSON.parse(l); |
| const payload = parsed.payload || {}; |
| const type = parsed.type; |
| const timestamp = parsed.timestamp; |
|
|
| if (type === 'console') { |
| consoleLogs.push({ |
| ...payload, |
| type, |
| timestamp, |
| id: payload.id || Math.random().toString(36).substring(2, 11), |
| }); |
| } else if (type === 'network') { |
| const id = payload.id; |
| if (!id) return; |
|
|
| if (!networkMap.has(id)) { |
| networkMap.set(id, { |
| ...payload, |
| type, |
| timestamp, |
| id, |
| } as NetworkLog); |
| } else { |
| |
| const existing = networkMap.get(id)!; |
| networkMap.set(id, { |
| ...existing, |
| ...payload, |
| |
| type: existing.type, |
| timestamp: existing.timestamp, |
| } as NetworkLog); |
| } |
| } |
| }); |
|
|
| const importId = `[Imported] ${file.name}`; |
| const networkLogs = Array.from(networkMap.values()).sort( |
| (a, b) => a.timestamp - b.timestamp, |
| ); |
|
|
| setImportedLogs({ network: networkLogs, console: consoleLogs }); |
| setImportedSessionId(importId); |
| setSelectedSessionId(importId); |
| } catch (err) { |
| console.error('Import error:', err); |
| alert('Failed to parse session file. Ensure it is a valid JSONL file.'); |
| } |
| }; |
| reader.readAsText(file); |
| e.target.value = ''; |
| }; |
|
|
| |
| const handleExport = () => { |
| if (!selectedSessionId) return; |
|
|
| |
| const entries: Array<{ timestamp: number; data: object }> = []; |
|
|
| |
| filteredConsoleLogs.forEach((log) => { |
| entries.push({ |
| timestamp: log.timestamp, |
| data: { |
| type: 'console', |
| payload: { type: log.type, content: log.content }, |
| sessionId: log.sessionId, |
| timestamp: log.timestamp, |
| }, |
| }); |
| }); |
|
|
| |
| filteredNetworkLogs.forEach((log) => { |
| entries.push({ |
| timestamp: log.timestamp, |
| data: { |
| type: 'network', |
| payload: log, |
| sessionId: log.sessionId, |
| timestamp: log.timestamp, |
| }, |
| }); |
| }); |
|
|
| |
| entries.sort((a, b) => a.timestamp - b.timestamp); |
|
|
| const content = entries.map((e) => JSON.stringify(e.data)).join('\n'); |
| const blob = new Blob([content], { type: 'application/jsonl' }); |
| const url = URL.createObjectURL(blob); |
|
|
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `session-${selectedSessionId}.jsonl`; |
| a.click(); |
|
|
| URL.revokeObjectURL(url); |
| }; |
|
|
| |
| const sessions = useMemo(() => { |
| const sessionMap = new Map<string, number>(); |
| const updateMap = (l: { sessionId?: string; timestamp: number }) => { |
| if (!l.sessionId) return; |
| const currentMax = sessionMap.get(l.sessionId) || 0; |
| if (l.timestamp > currentMax) sessionMap.set(l.sessionId, l.timestamp); |
| }; |
| networkLogs.forEach(updateMap); |
| consoleLogs.forEach(updateMap); |
|
|
| const discovered = Array.from(sessionMap.entries()) |
| .sort((a, b) => b[1] - a[1]) |
| .map((entry) => entry[0]); |
|
|
| if (importedSessionId) { |
| return [importedSessionId, ...discovered]; |
| } |
| return discovered; |
| }, [networkLogs, consoleLogs, importedSessionId]); |
|
|
| useEffect(() => { |
| if (sessions.length > 0 && selectedSessionId === null) { |
| setSelectedSessionId(sessions[0]); |
| } |
| }, [sessions, selectedSessionId]); |
|
|
| const filteredConsoleLogs = useMemo(() => { |
| if (!selectedSessionId) return []; |
| if (selectedSessionId === importedSessionId && importedLogs) { |
| return importedLogs.console; |
| } |
| return consoleLogs.filter((l) => l.sessionId === selectedSessionId); |
| }, [consoleLogs, selectedSessionId, importedSessionId, importedLogs]); |
|
|
| const filteredNetworkLogs = useMemo(() => { |
| if (!selectedSessionId) return []; |
| if (selectedSessionId === importedSessionId && importedLogs) { |
| return importedLogs.network; |
| } |
| return networkLogs.filter((l) => l.sessionId === selectedSessionId); |
| }, [networkLogs, selectedSessionId, importedSessionId, importedLogs]); |
|
|
| return ( |
| <div |
| style={{ |
| display: 'flex', |
| flexDirection: 'column', |
| height: '100vh', |
| background: t.bg, |
| color: t.text, |
| transition: 'background 0.2s, color 0.2s', |
| fontFamily: |
| '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', |
| }} |
| > |
| <style>{` |
| ::-webkit-scrollbar { width: 8px; height: 8px; } |
| ::-webkit-scrollbar-track { background: ${t.bgSecondary}; } |
| ::-webkit-scrollbar-thumb { background: ${t.border}; border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: ${t.textSecondary}; } |
| [data-gutter]::selection, [data-gutter] *::selection { background: transparent; } |
| [data-gutter] .fold-icon { opacity: 0; transition: opacity 0.15s; } |
| [data-code-view]:has([data-gutter]:hover) .fold-icon { opacity: 1; } |
| `}</style> |
| |
| {/* Toolbar */} |
| <div |
| style={{ |
| display: 'flex', |
| background: t.bgSecondary, |
| borderBottom: `1px solid ${t.border}`, |
| height: '36px', |
| alignItems: 'center', |
| padding: '0 8px', |
| gap: '12px', |
| }} |
| > |
| <div style={{ display: 'flex', height: '100%' }}> |
| <TabButton |
| active={activeTab === 'console'} |
| onClick={() => setActiveTab('console')} |
| label="Console" |
| t={t} |
| /> |
| <TabButton |
| active={activeTab === 'network'} |
| onClick={() => setActiveTab('network')} |
| label="Network" |
| t={t} |
| /> |
| </div> |
| |
| <div |
| style={{ |
| marginLeft: 'auto', |
| fontSize: '11px', |
| display: 'flex', |
| alignItems: 'center', |
| gap: '12px', |
| }} |
| > |
| {selectedSessionId && |
| connectedSessions.includes(selectedSessionId) && ( |
| <> |
| <button |
| onClick={async () => { |
| try { |
| await fetch('/api/trigger-debugger', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ sessionId: selectedSessionId }), |
| }); |
| showToast( |
| 'Node debugger attached. Open chrome://inspect in Chrome to start debugging.', |
| ); |
| } catch (e) { |
| console.error('Failed to trigger debugger:', e); |
| } |
| }} |
| style={{ |
| fontSize: '11px', |
| padding: '4px 8px', |
| border: `1px solid ${t.border}`, |
| background: t.bg, |
| color: t.text, |
| borderRadius: '4px', |
| cursor: 'pointer', |
| fontWeight: 600, |
| }} |
| title="Attach Node Debugger and open chrome://inspect" |
| > |
| 🐞 Debug Node |
| </button> |
| <button |
| onClick={handleExport} |
| style={{ |
| fontSize: '11px', |
| padding: '4px 8px', |
| border: `1px solid ${t.border}`, |
| background: t.bg, |
| color: t.text, |
| borderRadius: '4px', |
| cursor: 'pointer', |
| fontWeight: 600, |
| }} |
| > |
| 📤 Export |
| </button> |
| </> |
| )} |
| |
| <label |
| style={{ |
| padding: '2px 8px', |
| borderRadius: '4px', |
| border: `1px solid ${t.border}`, |
| background: t.bg, |
| cursor: 'pointer', |
| display: 'flex', |
| alignItems: 'center', |
| gap: '6px', |
| fontWeight: 600, |
| fontSize: '11px', |
| }} |
| > |
| <span>📥 Import</span> |
| <input |
| type="file" |
| accept=".jsonl" |
| onChange={handleImport} |
| style={{ display: 'none' }} |
| /> |
| </label> |
| |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: '6px', |
| }} |
| > |
| <span style={{ fontSize: '11px', color: t.textSecondary }}> |
| Session: |
| </span> |
| {sessions.length > 0 ? ( |
| <select |
| value={selectedSessionId || ''} |
| onChange={(e) => setSelectedSessionId(e.target.value)} |
| style={{ |
| fontSize: '11px', |
| padding: '2px 8px', |
| background: t.bg, |
| color: t.text, |
| border: `1px solid ${t.border}`, |
| borderRadius: '3px', |
| minWidth: '280px', |
| outline: 'none', |
| }} |
| > |
| {sessions.map((id) => ( |
| <option key={id} value={id}> |
| {id}{' '} |
| {id === sessions[0] && !id.startsWith('[Imported]') |
| ? '(Latest)' |
| : ''} |
| </option> |
| ))} |
| </select> |
| ) : ( |
| <span |
| style={{ |
| fontSize: '11px', |
| color: t.textSecondary, |
| fontStyle: 'italic', |
| }} |
| > |
| No Sessions |
| </span> |
| )} |
| {selectedSessionId && |
| !selectedSessionId.startsWith('[Imported]') && ( |
| <span |
| style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: '4px', |
| fontSize: '11px', |
| marginLeft: '8px', |
| }} |
| > |
| <span |
| style={{ |
| width: '6px', |
| height: '6px', |
| borderRadius: '50%', |
| background: connectedSessions.includes(selectedSessionId) |
| ? '#34a853' |
| : '#ea4335', |
| }} |
| /> |
| <span style={{ color: t.textSecondary }}> |
| {connectedSessions.includes(selectedSessionId) |
| ? 'Connected' |
| : 'Disconnected'} |
| </span> |
| </span> |
| )} |
| </div> |
| |
| <button |
| onClick={toggleTheme} |
| style={{ |
| fontSize: '14px', |
| padding: '2px 8px', |
| border: `1px solid ${t.border}`, |
| background: t.bg, |
| color: t.text, |
| borderRadius: '4px', |
| cursor: 'pointer', |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| height: '24px', |
| width: '32px', |
| }} |
| title={isDark ? 'Switch to light mode' : 'Switch to dark mode'} |
| > |
| {isDark ? '🌙' : '☀️'} |
| </button> |
| </div> |
| </div> |
|
|
| {} |
| <div |
| style={{ |
| flex: 1, |
| overflow: 'hidden', |
| display: 'flex', |
| flexDirection: 'column', |
| }} |
| > |
| {selectedSessionId ? ( |
| <> |
| <div |
| style={{ |
| display: activeTab === 'console' ? 'flex' : 'none', |
| height: '100%', |
| }} |
| > |
| <ConsoleView logs={filteredConsoleLogs} t={t} /> |
| </div> |
| <div |
| style={{ |
| display: activeTab === 'network' ? 'flex' : 'none', |
| height: '100%', |
| }} |
| > |
| <NetworkView logs={filteredNetworkLogs} t={t} isDark={isDark} /> |
| </div> |
| </> |
| ) : ( |
| <div |
| style={{ |
| flex: 1, |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| color: t.textSecondary, |
| fontSize: '14px', |
| }} |
| > |
| Please start Gemini CLI to begin debugging |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {toastMessage && ( |
| <div |
| style={{ |
| position: 'fixed', |
| bottom: '24px', |
| right: '24px', |
| background: t.accent, |
| color: '#fff', |
| padding: '12px 24px', |
| borderRadius: '8px', |
| boxShadow: '0 4px 12px rgba(0,0,0,0.15)', |
| fontSize: '13px', |
| fontWeight: 500, |
| zIndex: 1000, |
| animation: 'fadeInOut 5s ease forwards', |
| }} |
| > |
| {toastMessage} |
| </div> |
| )} |
|
|
| {} |
| <style>{` |
| @keyframes fadeInOut { |
| 0% { opacity: 0; transform: translateY(10px); } |
| 5% { opacity: 1; transform: translateY(0); } |
| 95% { opacity: 1; transform: translateY(0); } |
| 100% { opacity: 0; transform: translateY(10px); } |
| } |
| `}</style> |
| </div> |
| ); |
| } |
|
|
| function TabButton({ |
| active, |
| onClick, |
| label, |
| t, |
| }: { |
| active: boolean; |
| onClick: () => void; |
| label: string; |
| t: ThemeColors; |
| }) { |
| return ( |
| <div |
| onClick={onClick} |
| style={{ |
| padding: '4px 16px', |
| cursor: 'pointer', |
| color: active ? t.accent : t.textSecondary, |
| fontWeight: 600, |
| fontSize: '12px', |
| userSelect: 'none', |
| borderBottom: active |
| ? `2px solid ${t.accent}` |
| : '2px solid transparent', |
| height: '100%', |
| boxSizing: 'border-box', |
| display: 'flex', |
| alignItems: 'center', |
| transition: 'all 0.2s', |
| }} |
| > |
| {label} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function ConsoleLogEntry({ log, t }: { log: ConsoleLog; t: ThemeColors }) { |
| const [isExpanded, setIsExpanded] = useState(false); |
| const content = log.content || ''; |
| const lines = content.split('\n'); |
| const CHAR_LIMIT = 500; |
| const LINE_LIMIT = 5; |
|
|
| const isTooLong = content.length > CHAR_LIMIT; |
| const isTooManyLines = lines.length > LINE_LIMIT; |
| const needsCollapse = isTooLong || isTooManyLines; |
|
|
| const isError = log.type === 'error'; |
| const isWarn = log.type === 'warn'; |
| const bg = isError ? t.errorBg : isWarn ? t.warnBg : 'transparent'; |
| const color = isError ? t.errorText : isWarn ? t.warnText : t.text; |
| const icon = isError ? '❌' : isWarn ? '⚠️' : ' '; |
|
|
| let displayContent = content; |
| if (needsCollapse && !isExpanded) { |
| if (isTooManyLines) { |
| displayContent = lines.slice(0, LINE_LIMIT).join('\n') + '\n...'; |
| } else { |
| displayContent = content.substring(0, CHAR_LIMIT) + '...'; |
| } |
| } |
|
|
| return ( |
| <div |
| style={{ |
| display: 'flex', |
| |
| borderBottom: `1px solid ${t.rowBorder}`, |
| |
| padding: '4px 12px', |
| |
| backgroundColor: bg, |
| |
| alignItems: 'flex-start', |
| |
| gap: '8px', |
| }} |
| > |
| <div |
| style={{ |
| width: '16px', |
| |
| textAlign: 'center', |
| |
| flexShrink: 0, |
| |
| fontSize: '10px', |
| |
| marginTop: '2px', |
| }} |
| > |
| {icon} |
| </div> |
| |
| <div |
| style={{ |
| flex: 1, |
| |
| display: 'flex', |
| |
| flexDirection: 'column', |
| }} |
| > |
| <div |
| style={{ |
| whiteSpace: 'pre-wrap', |
| |
| wordBreak: 'break-all', |
| |
| color: color, |
| |
| lineHeight: '1.5', |
| |
| fontSize: '11px', |
| }} |
| > |
| {displayContent} |
| </div> |
| </div> |
| |
| <div |
| style={{ |
| display: 'flex', |
| |
| alignItems: 'center', |
| |
| gap: '8px', |
| |
| flexShrink: 0, |
| }} |
| > |
| {needsCollapse && ( |
| <div |
| onClick={() => setIsExpanded(!isExpanded)} |
| style={{ |
| fontSize: '12px', |
| |
| color: t.text, |
| |
| cursor: 'pointer', |
| |
| fontWeight: 'bold', |
| |
| userSelect: 'none', |
| |
| width: '20px', |
| |
| height: '20px', |
| |
| display: 'flex', |
| |
| alignItems: 'center', |
| |
| justifyContent: 'center', |
| |
| borderRadius: '4px', |
| |
| border: `1px solid ${t.border}`, |
| |
| background: t.bgSecondary, |
| |
| transition: 'all 0.1s', |
| }} |
| onMouseOver={(e) => { |
| (e.currentTarget as HTMLDivElement).style.background = t.bgHover; |
| }} |
| onMouseOut={(e) => { |
| (e.currentTarget as HTMLDivElement).style.background = |
| t.bgSecondary; |
| }} |
| title={isExpanded ? 'Collapse' : 'Expand'} |
| > |
| {isExpanded ? '−' : '+'} |
| </div> |
| )} |
| |
| <div |
| style={{ |
| color: t.textSecondary, |
| |
| fontSize: '10px', |
| |
| userSelect: 'none', |
| |
| textAlign: 'right', |
| |
| minWidth: '70px', |
| }} |
| > |
| {new Date(log.timestamp).toLocaleTimeString([], { |
| hour12: false, |
| |
| hour: '2-digit', |
| |
| minute: '2-digit', |
| |
| second: '2-digit', |
| })} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ConsoleView({ logs, t }: { logs: ConsoleLog[]; t: ThemeColors }) { |
| const bottomRef = useRef<HTMLDivElement>(null); |
|
|
| useEffect(() => { |
| bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| }, [logs.length]); |
|
|
| if (logs.length === 0) { |
| return ( |
| <div |
| style={{ |
| padding: '20px', |
| |
| color: t.textSecondary, |
| |
| fontSize: '11px', |
| |
| textAlign: 'center', |
| |
| flex: 1, |
| }} |
| > |
| No console logs in this session |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div |
| style={{ |
| flex: 1, |
| |
| overflowY: 'auto', |
| |
| fontFamily: |
| 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace', |
| |
| background: t.consoleBg, |
| |
| fontSize: '12px', |
| }} |
| > |
| {logs.map((log) => ( |
| <ConsoleLogEntry key={log.id} log={log} t={t} /> |
| ))} |
| |
| <div ref={bottomRef} /> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function NetworkView({ |
| logs, |
| t, |
| isDark, |
| }: { |
| logs: NetworkLog[]; |
| t: ThemeColors; |
| isDark: boolean; |
| }) { |
| const [selectedId, setSelectedId] = useState<string | null>(null); |
| const [filter, setFilter] = useState(''); |
| const [groupByDomain, setGroupByDomain] = useState(true); |
| const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>( |
| {}, |
| ); |
| const [sidebarWidth, setSidebarWidth] = useState(400); |
| const isResizing = useRef(false); |
|
|
| useEffect(() => { |
| const handleMouseMove = (e: MouseEvent) => { |
| if (!isResizing.current) return; |
| const newWidth = Math.max( |
| 200, |
| Math.min(e.clientX, window.innerWidth - 200), |
| ); |
| setSidebarWidth(newWidth); |
| }; |
| const handleMouseUp = () => { |
| isResizing.current = false; |
| document.body.style.cursor = 'default'; |
| document.body.style.userSelect = 'auto'; |
| }; |
| document.addEventListener('mousemove', handleMouseMove); |
| document.addEventListener('mouseup', handleMouseUp); |
| return () => { |
| document.removeEventListener('mousemove', handleMouseMove); |
| document.removeEventListener('mouseup', handleMouseUp); |
| }; |
| }, []); |
|
|
| const startResizing = () => { |
| isResizing.current = true; |
| document.body.style.cursor = 'col-resize'; |
| document.body.style.userSelect = 'none'; |
| }; |
|
|
| const filteredLogs = useMemo(() => { |
| let result = logs; |
| if (filter) { |
| const lower = filter.toLowerCase(); |
| result = logs.filter((l) => l.url.toLowerCase().includes(lower)); |
| } |
| return result; |
| }, [logs, filter]); |
|
|
| const groupedLogs = useMemo(() => { |
| if (!groupByDomain) return null; |
| const groups: Record<string, NetworkLog[]> = {}; |
| filteredLogs.forEach((log) => { |
| let groupKey = 'Other'; |
| try { |
| const url = new URL(log.url); |
| const lastSlashIndex = url.pathname.lastIndexOf('/'); |
| const basePath = |
| lastSlashIndex !== -1 |
| ? url.pathname.substring(0, lastSlashIndex + 1) |
| : '/'; |
| groupKey = url.hostname + basePath; |
| } catch { |
| |
| } |
| if (!groups[groupKey]) groups[groupKey] = []; |
| groups[groupKey].push(log); |
| }); |
| return groups; |
| }, [filteredLogs, groupByDomain]); |
|
|
| useEffect(() => { |
| if (groupedLogs) { |
| setExpandedGroups((prev) => { |
| const next = { ...prev }; |
| Object.keys(groupedLogs).forEach((key) => { |
| if (next[key] === undefined) { |
| |
| next[key] = !key.includes('play.googleapis.com'); |
| } |
| }); |
| return next; |
| }); |
| } |
| }, [groupedLogs]); |
|
|
| const toggleGroup = (key: string) => { |
| setExpandedGroups((prev) => ({ ...prev, [key]: !prev[key] })); |
| }; |
|
|
| |
|
|
| const selectedLog = logs.find((l) => l.id === selectedId); |
|
|
| const renderLogItem = (log: NetworkLog, nameOverride?: string) => { |
| const isPending = log.pending; |
| const status = log.response |
| ? log.response.status |
| : log.error |
| ? 'ERR' |
| : '...'; |
| const isError = log.error || (log.response && log.response.status >= 400); |
|
|
| let name = nameOverride || log.url; |
| if (!nameOverride) { |
| try { |
| const urlObj = new URL(log.url); |
| name = urlObj.pathname + urlObj.search; |
| } catch { |
| |
| } |
| } |
|
|
| const isSelected = log.id === selectedId; |
|
|
| return ( |
| <div |
| key={log.id} |
| onClick={() => setSelectedId(log.id)} |
| style={{ |
| padding: '8px 12px', |
| cursor: 'pointer', |
| borderBottom: `1px solid ${t.rowBorder}`, |
| display: 'flex', |
| flexDirection: 'column', |
| fontSize: '12px', |
| backgroundColor: isSelected ? t.bgHover : 'transparent', |
| color: isError ? '#f28b82' : t.text, |
| paddingLeft: nameOverride ? '24px' : '12px', |
| }} |
| > |
| <div style={{ display: 'flex', alignItems: 'center' }}> |
| <span |
| style={{ |
| fontWeight: 'bold', |
| width: '45px', |
| flexShrink: 0, |
| fontSize: '10px', |
| color: isDark ? '#81c995' : '#188038', // Green for methods |
| }} |
| > |
| {log.method} |
| </span> |
| <span |
| style={{ |
| flex: 1, |
| whiteSpace: 'nowrap', |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| margin: '0 8px', |
| fontWeight: 500, |
| }} |
| title={log.url} |
| > |
| {name} |
| </span> |
| <span |
| style={{ |
| width: '40px', |
| textAlign: 'right', |
| flexShrink: 0, |
| fontSize: '11px', |
| color: isPending ? t.accent : isError ? '#f28b82' : '#81c995', |
| }} |
| > |
| {isPending ? '⏳' : status} |
| </span> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| if (logs.length === 0) { |
| return ( |
| <div |
| style={{ |
| flex: 1, |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| color: t.textSecondary, |
| fontSize: '12px', |
| }} |
| > |
| No network activity in this session |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div style={{ display: 'flex', width: '100%', height: '100%' }}> |
| {/* List */} |
| <div |
| style={{ |
| width: `${sidebarWidth}px`, |
| display: 'flex', |
| flexDirection: 'column', |
| borderRight: `1px solid ${t.border}`, |
| background: t.bg, |
| }} |
| > |
| <div |
| style={{ |
| padding: '6px', |
| background: t.bgSecondary, |
| borderBottom: `1px solid ${t.border}`, |
| display: 'flex', |
| gap: '6px', |
| }} |
| > |
| <input |
| type="text" |
| placeholder="Filter..." |
| value={filter} |
| onChange={(e) => setFilter(e.target.value)} |
| style={{ |
| flex: 1, |
| boxSizing: 'border-box', |
| padding: '4px 10px', |
| background: t.bg, |
| color: t.text, |
| border: `1px solid ${t.border}`, |
| borderRadius: '4px', |
| fontSize: '12px', |
| }} |
| /> |
| <button |
| onClick={() => setGroupByDomain(!groupByDomain)} |
| style={{ |
| background: groupByDomain ? t.accent : t.bg, |
| color: groupByDomain ? '#fff' : t.text, |
| border: `1px solid ${t.border}`, |
| borderRadius: '4px', |
| cursor: 'pointer', |
| fontSize: '12px', |
| padding: '0 8px', |
| }} |
| title="Group by Domain" |
| > |
| 📂 |
| </button> |
| </div> |
| <div style={{ flex: 1, overflowY: 'auto' }}> |
| {groupByDomain && groupedLogs |
| ? Object.keys(groupedLogs).map((groupKey) => ( |
| <div key={groupKey}> |
| <div |
| onClick={() => toggleGroup(groupKey)} |
| style={{ |
| padding: '6px 12px', |
| background: t.bgSecondary, |
| fontWeight: 'bold', |
| fontSize: '11px', |
| borderBottom: `1px solid ${t.rowBorder}`, |
| wordBreak: 'break-all', |
| cursor: 'pointer', |
| display: 'flex', |
| alignItems: 'center', |
| userSelect: 'none', |
| }} |
| > |
| <span |
| style={{ |
| marginRight: '8px', |
| fontSize: '9px', |
| color: t.textSecondary, |
| }} |
| > |
| {expandedGroups[groupKey] ? '▼' : '▶'} |
| </span> |
| {groupKey} |
| <span |
| style={{ |
| marginLeft: 'auto', |
| fontWeight: 'normal', |
| color: t.textSecondary, |
| fontSize: '10px', |
| background: t.bg, |
| padding: '0 6px', |
| borderRadius: '10px', |
| }} |
| > |
| {groupedLogs[groupKey].length} |
| </span> |
| </div> |
| {expandedGroups[groupKey] && |
| groupedLogs[groupKey].map((log) => { |
| let displayName = log.url; |
| try { |
| const url = new URL(log.url); |
| const lastSlashIndex = url.pathname.lastIndexOf('/'); |
| const suffix = url.pathname.substring( |
| lastSlashIndex + 1, |
| ); |
| displayName = (suffix || '/') + url.search; |
| } catch { |
| /* ignore */ |
| } |
| return renderLogItem(log, displayName); |
| })} |
| </div> |
| )) |
| : filteredLogs.map((log) => renderLogItem(log))} |
| </div> |
| </div> |
| |
| {/* Resizer */} |
| <div |
| onMouseDown={startResizing} |
| style={{ |
| width: '2px', |
| cursor: 'col-resize', |
| background: t.border, |
| flexShrink: 0, |
| zIndex: 10, |
| }} |
| /> |
| |
| {/* Detail */} |
| <div |
| style={{ |
| flex: 1, |
| display: 'flex', |
| flexDirection: 'column', |
| overflow: 'hidden', |
| background: t.bg, |
| }} |
| > |
| {selectedLog ? ( |
| <NetworkDetail log={selectedLog} t={t} /> |
| ) : ( |
| <div |
| style={{ |
| padding: '40px', |
| textAlign: 'center', |
| color: t.textSecondary, |
| fontSize: '14px', |
| }} |
| > |
| Select a request to view details |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| type Tab = 'headers' | 'payload' | 'response'; |
|
|
| function NetworkDetail({ log, t }: { log: NetworkLog; t: ThemeColors }) { |
| const [activeTab, setActiveTab] = useState<Tab>('headers'); |
| const status = log.response |
| ? log.pending |
| ? '⏳' |
| : log.response.status |
| : log.error |
| ? 'Error' |
| : '⏳'; |
|
|
| return ( |
| <div |
| style={{ |
| display: 'flex', |
| flexDirection: 'column', |
| height: '100%', |
| overflow: 'hidden', |
| }} |
| > |
| <div |
| style={{ |
| padding: '12px 16px', |
| borderBottom: `1px solid ${t.border}`, |
| background: t.bgSecondary, |
| }} |
| > |
| <div |
| style={{ |
| fontWeight: 'bold', |
| fontSize: '13px', |
| marginBottom: '6px', |
| wordBreak: 'break-all', |
| color: t.text, |
| }} |
| > |
| {log.url} |
| </div> |
| <div |
| style={{ |
| fontSize: '11px', |
| color: t.textSecondary, |
| display: 'flex', |
| gap: '8px', |
| }} |
| > |
| <span |
| style={{ |
| background: t.bg, |
| padding: '1px 6px', |
| borderRadius: '3px', |
| fontWeight: 'bold', |
| }} |
| > |
| {log.method} |
| </span> |
| <span>•</span> |
| <span style={{ color: log.error ? '#f28b82' : '#81c995' }}> |
| {status} |
| </span> |
| <span>•</span> |
| <span>{new Date(log.timestamp).toLocaleTimeString()}</span> |
| {log.response && ( |
| <> |
| <span>•</span> |
| <span style={{ color: t.accent }}> |
| {log.response.durationMs}ms |
| </span> |
| </> |
| )} |
| </div> |
| </div> |
| <div |
| style={{ |
| display: 'flex', |
| borderBottom: `1px solid ${t.border}`, |
| background: t.bgSecondary, |
| paddingLeft: '8px', |
| }} |
| > |
| {(['headers', 'payload', 'response'] as const).map((tab) => ( |
| <div |
| key={tab} |
| onClick={() => setActiveTab(tab)} |
| style={{ |
| padding: '8px 16px', |
| cursor: 'pointer', |
| fontWeight: 600, |
| fontSize: '12px', |
| textTransform: 'capitalize', |
| borderBottom: |
| activeTab === tab |
| ? `2px solid ${t.accent}` |
| : '2px solid transparent', |
| color: activeTab === tab ? t.accent : t.textSecondary, |
| transition: 'all 0.2s', |
| }} |
| > |
| {tab} |
| </div> |
| ))} |
| </div> |
| <div style={{ flex: 1, overflowY: 'auto', background: t.bg }}> |
| {activeTab === 'headers' && ( |
| <div style={{ padding: '16px' }}> |
| <Section title="General" t={t}> |
| <Pair k="Request URL" v={log.url} t={t} /> |
| <Pair k="Request Method" v={log.method} t={t} /> |
| <Pair |
| k="Status Code" |
| v={String(log.response ? log.response.status : 'Pending')} |
| t={t} |
| color={log.error ? '#f28b82' : '#81c995'} |
| /> |
| {log.error && ( |
| <Pair k="Error" v={log.error} t={t} color="#f28b82" /> |
| )} |
| </Section> |
| <Section title="Response Headers" t={t}> |
| {log.response ? ( |
| <HeadersMap headers={log.response.headers} t={t} /> |
| ) : ( |
| <span style={{ fontStyle: 'italic', color: t.textSecondary }}> |
| (no response yet) |
| </span> |
| )} |
| </Section> |
| <Section title="Request Headers" t={t}> |
| <HeadersMap headers={log.headers} t={t} /> |
| </Section> |
| </div> |
| )} |
| {activeTab === 'payload' && <BodyView content={log.body} t={t} />} |
| {activeTab === 'response' && ( |
| <BodyView content={log.response?.body} chunks={log.chunks} t={t} /> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function Section({ |
| title, |
| children, |
| t, |
| }: { |
| title: string; |
| children: React.ReactNode; |
| t: ThemeColors; |
| }) { |
| const [collapsed, setCollapsed] = useState(false); |
| return ( |
| <div |
| style={{ |
| marginBottom: '16px', |
| border: `1px solid ${t.border}`, |
| borderRadius: '6px', |
| overflow: 'hidden', |
| }} |
| > |
| <div |
| onClick={() => setCollapsed(!collapsed)} |
| style={{ |
| padding: '8px 12px', |
| background: t.bgSecondary, |
| fontWeight: 'bold', |
| fontSize: '11px', |
| cursor: 'pointer', |
| userSelect: 'none', |
| display: 'flex', |
| alignItems: 'center', |
| gap: '8px', |
| }} |
| > |
| <span style={{ fontSize: '9px', color: t.textSecondary }}> |
| {collapsed ? '▶' : '▼'} |
| </span> |
| {title} |
| </div> |
| {!collapsed && ( |
| <div style={{ padding: '12px', background: t.bg }}>{children}</div> |
| )} |
| </div> |
| ); |
| } |
|
|
| function Pair({ |
| k, |
| v, |
| color, |
| t, |
| }: { |
| k: string; |
| v: string; |
| color?: string; |
| t: ThemeColors; |
| }) { |
| return ( |
| <div |
| style={{ |
| display: 'flex', |
| fontSize: '12px', |
| fontFamily: 'monospace', |
| marginBottom: '4px', |
| lineHeight: '1.4', |
| }} |
| > |
| <div |
| style={{ |
| fontWeight: 'bold', |
| color: t.textSecondary, |
| width: '160px', |
| flexShrink: 0, |
| }} |
| > |
| {k}: |
| </div> |
| <div style={{ flex: 1, wordBreak: 'break-all', color: color || t.text }}> |
| {v} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function HeadersMap({ |
| headers, |
| t, |
| }: { |
| headers: Record<string, unknown> | undefined; |
| t: ThemeColors; |
| }) { |
| if (!headers) return <div style={{ color: t.textSecondary }}>(none)</div>; |
| return ( |
| <> |
| {Object.entries(headers).map(([k, v]) => ( |
| <Pair key={k} k={k} v={String(v)} t={t} /> |
| ))} |
| </> |
| ); |
| } |
|
|
| function BodyView({ |
| content, |
| chunks, |
| t, |
| }: { |
| content?: string; |
| chunks?: Array<{ index: number; data: string; timestamp: number }>; |
| t: ThemeColors; |
| }) { |
| const [mode, setMode] = useState<'json' | 'raw'>('json'); |
| const hasChunks = chunks && chunks.length > 0; |
| const safeContent = hasChunks |
| ? chunks.map((c) => c.data).join('') |
| : content || ''; |
| const getFormattedJson = () => { |
| try { |
| return JSON.stringify(JSON.parse(safeContent), null, 2); |
| } catch { |
| return safeContent; |
| } |
| }; |
|
|
| const copyJson = () => { |
| navigator.clipboard.writeText(getFormattedJson()).catch(() => { |
| |
| }); |
| }; |
|
|
| const downloadJson = () => { |
| const blob = new Blob([getFormattedJson()], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = 'body.json'; |
| a.click(); |
| URL.revokeObjectURL(url); |
| }; |
|
|
| if (!safeContent && !hasChunks) |
| return ( |
| <div |
| style={{ padding: '40px', color: t.textSecondary, textAlign: 'center' }} |
| > |
| (No content) |
| </div> |
| ); |
|
|
| const iconBtn = { |
| background: 'none', |
| border: 'none', |
| cursor: 'pointer', |
| color: t.textSecondary, |
| padding: '2px', |
| borderRadius: '4px', |
| display: 'flex', |
| alignItems: 'center', |
| } as const; |
|
|
| return ( |
| <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> |
| <div |
| style={{ |
| padding: '6px 12px', |
| background: t.bgSecondary, |
| borderBottom: `1px solid ${t.border}`, |
| display: 'flex', |
| gap: '8px', |
| alignItems: 'center', |
| }} |
| > |
| {(['json', 'raw'] as const).map((m) => ( |
| <button |
| key={m} |
| onClick={() => setMode(m)} |
| style={{ |
| fontSize: '11px', |
| padding: '2px 8px', |
| borderRadius: '4px', |
| border: `1px solid ${t.border}`, |
| background: mode === m ? t.accent : t.bg, |
| color: mode === m ? '#fff' : t.text, |
| cursor: 'pointer', |
| textTransform: 'uppercase', |
| fontWeight: 'bold', |
| }} |
| > |
| {m} |
| </button> |
| ))} |
| <div style={{ flex: 1 }} /> |
| <button onClick={copyJson} style={iconBtn} title="Copy JSON"> |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> |
| <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25z" /> |
| <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z" /> |
| </svg> |
| </button> |
| <button onClick={downloadJson} style={iconBtn} title="Download JSON"> |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> |
| <path d="M2.75 14A1.75 1.75 0 011 12.25v-2.5a.75.75 0 011.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25v-2.5a.75.75 0 011.5 0v2.5A1.75 1.75 0 0113.25 14z" /> |
| <path d="M7.25 7.689V2a.75.75 0 011.5 0v5.689l1.97-1.969a.749.749 0 111.06 1.06l-3.25 3.25a.749.749 0 01-1.06 0L4.22 6.78a.749.749 0 111.06-1.06z" /> |
| </svg> |
| </button> |
| </div> |
| <div |
| style={{ |
| flex: 1, |
| overflow: 'auto', |
| padding: mode === 'raw' ? '16px' : 0, |
| }} |
| > |
| {hasChunks && mode === 'raw' ? ( |
| <div> |
| {chunks.map((chunk, i) => ( |
| <div key={i} style={{ marginBottom: '12px' }}> |
| <div |
| style={{ |
| fontSize: '10px', |
| color: t.textSecondary, |
| marginBottom: '4px', |
| }} |
| > |
| [ |
| {new Date(chunk.timestamp).toLocaleTimeString('en-US', { |
| hour12: false, |
| })} |
| .{String(chunk.timestamp % 1000).padStart(3, '0')}] |
| </div> |
| <pre |
| style={{ |
| margin: 0, |
| fontSize: '12px', |
| fontFamily: 'SFMono-Regular, Consolas, monospace', |
| whiteSpace: 'pre-wrap', |
| wordBreak: 'break-all', |
| color: t.text, |
| lineHeight: '1.5', |
| }} |
| > |
| {chunk.data} |
| </pre> |
| </div> |
| ))} |
| </div> |
| ) : mode === 'raw' ? ( |
| <pre |
| style={{ |
| margin: 0, |
| fontSize: '12px', |
| fontFamily: 'SFMono-Regular, Consolas, monospace', |
| whiteSpace: 'pre-wrap', |
| wordBreak: 'break-all', |
| color: t.text, |
| lineHeight: '1.5', |
| }} |
| > |
| {safeContent} |
| </pre> |
| ) : ( |
| <JsonViewer content={safeContent} t={t} /> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function JsonViewer({ content, t }: { content: string; t: ThemeColors }) { |
| const safeContent = content || ''; |
| if (safeContent.includes('data:')) { |
| const chunks = safeContent |
| .split(/\n\s*\n/) |
| .map((eventBlock, i) => ({ |
| index: i + 1, |
| jsonStr: eventBlock |
| .split('\n') |
| .filter((line) => line.trim().startsWith('data:')) |
| .map((line) => line.trim().substring(5).trim()) |
| .join(''), |
| })) |
| .filter((c) => c.jsonStr); |
| if (chunks.length > 0) { |
| return ( |
| <div> |
| {chunks.map((chunk) => ( |
| <div |
| key={chunk.index} |
| style={{ |
| marginBottom: '12px', |
| borderLeft: `2px solid ${t.accent}`, |
| paddingLeft: '12px', |
| background: t.bgSecondary, |
| borderRadius: '0 4px 4px 0', |
| padding: '8px 12px', |
| }} |
| > |
| <div |
| style={{ |
| fontWeight: 'bold', |
| color: t.textSecondary, |
| fontSize: '10px', |
| marginBottom: '4px', |
| }} |
| > |
| CHUNK {chunk.index} |
| </div> |
| <CodeView data={tryParse(chunk.jsonStr)} t={t} /> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
| } |
| return <CodeView data={tryParse(safeContent)} t={t} />; |
| } |
|
|
| function tryParse(str: string) { |
| try { |
| return JSON.parse(str); |
| } catch { |
| return str; |
| } |
| } |
|
|
| interface JsonLine { |
| text: string; |
| foldStart: boolean; |
| foldEnd: number; |
| closingBracket: string; |
| } |
|
|
| function jsonToLines(data: unknown): JsonLine[] { |
| const str = |
| typeof data === 'string' && !data.startsWith('{') && !data.startsWith('[') |
| ? data |
| : JSON.stringify(data, null, 2); |
| if (str == null) |
| return [ |
| { text: 'undefined', foldStart: false, foldEnd: -1, closingBracket: '' }, |
| ]; |
| const raw = str.split('\n'); |
| const lines: JsonLine[] = raw.map((text) => ({ |
| text, |
| foldStart: false, |
| foldEnd: -1, |
| closingBracket: '', |
| })); |
| |
| const stack: Array<{ index: number; bracket: string }> = []; |
| for (let i = 0; i < raw.length; i++) { |
| const trimmed = raw[i].trimEnd(); |
| const last = trimmed[trimmed.length - 1]; |
| if (last === '{' || last === '[') { |
| stack.push({ index: i, bracket: last === '{' ? '}' : ']' }); |
| } |
| |
| const stripped = trimmed.replace(/,\s*$/, ''); |
| const closeChar = stripped[stripped.length - 1]; |
| if ( |
| (closeChar === '}' || closeChar === ']') && |
| stack.length > 0 && |
| stack[stack.length - 1].bracket === closeChar |
| ) { |
| const open = stack.pop()!; |
| lines[open.index].foldStart = true; |
| lines[open.index].foldEnd = i; |
| lines[open.index].closingBracket = open.bracket; |
| } |
| } |
| return lines; |
| } |
|
|
| |
| const TOKEN_RE = |
| /("(?:[^"\\]|\\.)*")\s*(?=:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}[\]:,])/g; |
|
|
| function highlightLine(text: string, t: ThemeColors): React.ReactNode { |
| const parts: React.ReactNode[] = []; |
| let lastIndex = 0; |
| let match: RegExpExecArray | null; |
| TOKEN_RE.lastIndex = 0; |
|
|
| while ((match = TOKEN_RE.exec(text)) !== null) { |
| |
| if (match.index > lastIndex) { |
| parts.push(text.slice(lastIndex, match.index)); |
| } |
| const [full, key, str, num, bool, nul] = match; |
| if (key) { |
| parts.push( |
| <span key={lastIndex} style={{ color: t.accent, fontWeight: 'bold' }}> |
| {full} |
| </span>, |
| ); |
| } else if (str) { |
| |
| |
| |
| let unescaped: string; |
| try { |
| unescaped = JSON.parse(full) as string; |
| } catch { |
| unescaped = full.slice(1, -1); |
| } |
| const strLines = unescaped.split('\n'); |
| if (strLines.length <= 1) { |
| parts.push( |
| <span key={lastIndex} style={{ color: '#81c995' }}> |
| {full} |
| </span>, |
| ); |
| } else { |
| const indent = ' '.repeat(match.index + 1); |
| parts.push( |
| <CollapsibleString |
| key={lastIndex} |
| lines={strLines} |
| indent={indent} |
| t={t} |
| />, |
| ); |
| } |
| } else if (num) { |
| parts.push( |
| <span key={lastIndex} style={{ color: '#ad7fa8' }}> |
| {full} |
| </span>, |
| ); |
| } else if (bool) { |
| parts.push( |
| <span key={lastIndex} style={{ color: '#fdd663', fontWeight: 'bold' }}> |
| {full} |
| </span>, |
| ); |
| } else if (nul) { |
| parts.push( |
| <span key={lastIndex} style={{ color: '#babdb6', fontWeight: 'bold' }}> |
| {full} |
| </span>, |
| ); |
| } else { |
| |
| parts.push(<span key={lastIndex}>{full}</span>); |
| } |
| lastIndex = match.index + full.length; |
| } |
| |
| if (lastIndex < text.length) { |
| parts.push(text.slice(lastIndex)); |
| } |
| return parts; |
| } |
|
|
| const STRING_LINE_THRESHOLD = 20; |
|
|
| function CollapsibleString({ |
| lines, |
| indent, |
| t, |
| }: { |
| lines: string[]; |
| indent: string; |
| t: ThemeColors; |
| }) { |
| const [expanded, setExpanded] = useState(false); |
| const needsTruncation = lines.length > STRING_LINE_THRESHOLD; |
| const displayLines = |
| needsTruncation && !expanded |
| ? lines.slice(0, STRING_LINE_THRESHOLD) |
| : lines; |
|
|
| return ( |
| <span style={{ color: '#81c995' }}> |
| "{displayLines[0]} |
| {displayLines.slice(1).map((sl, si) => ( |
| <React.Fragment key={si}> |
| {'\n'} |
| {indent} |
| {sl} |
| </React.Fragment> |
| ))} |
| {needsTruncation && ( |
| <> |
| {'\n'} |
| {indent} |
| <span |
| onClick={() => setExpanded(!expanded)} |
| style={{ |
| color: t.accent, |
| cursor: 'pointer', |
| fontStyle: 'italic', |
| userSelect: 'none', |
| }} |
| > |
| {expanded |
| ? '▲ collapse' |
| : `... ${lines.length - STRING_LINE_THRESHOLD} more lines`} |
| </span> |
| </> |
| )} |
| " |
| </span> |
| ); |
| } |
|
|
| function CodeView({ data, t }: { data: unknown; t: ThemeColors }) { |
| const lines = useMemo(() => jsonToLines(data), [data]); |
| const [collapsed, setCollapsed] = useState<Set<number>>(() => new Set()); |
| const contentRef = useRef<HTMLDivElement>(null); |
|
|
| const toggleFold = (lineIndex: number) => { |
| setCollapsed((prev) => { |
| const next = new Set(prev); |
| if (next.has(lineIndex)) { |
| next.delete(lineIndex); |
| } else { |
| next.add(lineIndex); |
| } |
| return next; |
| }); |
| }; |
|
|
| const handleKeyDown = (e: React.KeyboardEvent) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === 'a') { |
| e.preventDefault(); |
| const sel = window.getSelection(); |
| if (sel && contentRef.current) { |
| sel.removeAllRanges(); |
| const range = document.createRange(); |
| range.selectNodeContents(contentRef.current); |
| sel.addRange(range); |
| } |
| } |
| }; |
|
|
| |
| const visibleLines: Array<{ |
| index: number; |
| content: React.ReactNode; |
| foldable: boolean; |
| isCollapsed: boolean; |
| }> = []; |
|
|
| let i = 0; |
| while (i < lines.length) { |
| const line = lines[i]; |
| const isCollapsed = collapsed.has(i); |
| if (line.foldStart && isCollapsed) { |
| |
| const indent = line.text.match(/^(\s*)/)?.[1] || ''; |
| const trimmed = line.text.trimStart(); |
| visibleLines.push({ |
| index: i, |
| content: ( |
| <> |
| {indent.length > 0 && <span>{indent}</span>} |
| {highlightLine(trimmed, t)} |
| <span style={{ color: t.textSecondary, fontStyle: 'italic' }}> |
| {' '} |
| ... {line.closingBracket} |
| </span> |
| </> |
| ), |
| foldable: true, |
| isCollapsed: true, |
| }); |
| |
| i = line.foldEnd + 1; |
| } else { |
| visibleLines.push({ |
| index: i, |
| content: highlightLine(line.text, t), |
| foldable: line.foldStart, |
| isCollapsed: false, |
| }); |
| i++; |
| } |
| } |
|
|
| return ( |
| <div |
| tabIndex={0} |
| onKeyDown={handleKeyDown} |
| ref={contentRef} |
| data-code-view |
| style={{ |
| display: 'grid', |
| gridTemplateColumns: '20px 1fr', |
| fontFamily: |
| 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace', |
| fontSize: '12px', |
| lineHeight: '1.5', |
| outline: 'none', |
| }} |
| > |
| {visibleLines.map((vl) => ( |
| <React.Fragment key={vl.index}> |
| {/* Gutter cell */} |
| <div |
| data-gutter |
| style={{ |
| userSelect: 'none', |
| textAlign: 'center', |
| color: t.textSecondary, |
| borderRight: `1px solid ${t.border}`, |
| paddingRight: '2px', |
| cursor: vl.foldable ? 'pointer' : 'default', |
| fontSize: '9px', |
| paddingTop: '3px', |
| }} |
| onClick={vl.foldable ? () => toggleFold(vl.index) : undefined} |
| > |
| {vl.foldable ? ( |
| <span className="fold-icon">{vl.isCollapsed ? '▶' : '▼'}</span> |
| ) : ( |
| '' |
| )} |
| </div> |
| {/* Content cell */} |
| <div |
| style={{ |
| whiteSpace: 'pre', |
| color: t.text, |
| paddingLeft: '8px', |
| minHeight: '18px', |
| }} |
| > |
| {vl.content} |
| </div> |
| </React.Fragment> |
| ))} |
| </div> |
| ); |
| } |
|
|