/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks'; type ThemeMode = 'light' | 'dark' | null; // null means follow system interface ThemeColors { bg: string; bgSecondary: string; bgHover: string; border: string; text: string; textSecondary: string; accent: string; consoleBg: string; rowBorder: string; errorBg: string; warnBg: string; } export default function App() { const [activeTab, setActiveTab] = useState<'console' | 'network'>('console'); const { networkLogs, consoleLogs, connectedSessions } = useDevToolsData(); const [selectedSessionId, setSelectedSessionId] = useState( null, ); const [importedLogs, setImportedLogs] = useState<{ network: NetworkLog[]; console: ConsoleLog[]; } | null>(null); const [importedSessionId, setImportedSessionId] = useState( null, ); // --- Theme Logic --- const [themeMode, setThemeMode] = useState(() => { const saved = localStorage.getItem('devtools-theme'); if (!saved) return null; // Default: follow system 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', warnBg: isDark ? '#302a10' : '#fff3cd', }), [isDark], ); const toggleTheme = () => { const nextMode = isDark ? 'light' : 'dark'; setThemeMode(nextMode); localStorage.setItem('devtools-theme', nextMode); }; // --- Import Logic --- const handleImport = (e: React.ChangeEvent) => { 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(); 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 { // It's likely a response update const existing = networkMap.get(id)!; networkMap.set(id, { ...existing, ...payload, // Ensure we don't overwrite the original timestamp or type 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 = ''; }; // --- Export Logic --- const handleExport = () => { if (!selectedSessionId) return; // Collect entries with timestamps, then sort before serializing const entries: Array<{ timestamp: number; data: object }> = []; // Export console logs filteredConsoleLogs.forEach((log) => { entries.push({ timestamp: log.timestamp, data: { type: 'console', payload: { type: log.type, content: log.content }, sessionId: log.sessionId, timestamp: log.timestamp, }, }); }); // Export network logs filteredNetworkLogs.forEach((log) => { entries.push({ timestamp: log.timestamp, data: { type: 'network', payload: log, sessionId: log.sessionId, timestamp: log.timestamp, }, }); }); // Sort by timestamp, then serialize 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); }; // --- Session Discovery --- const sessions = useMemo(() => { const sessionMap = new Map(); 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 (
{/* Toolbar */}
setActiveTab('console')} label="Console" t={t} /> setActiveTab('network')} label="Network" t={t} />
{selectedSessionId && connectedSessions.includes(selectedSessionId) && ( )}
Session: {sessions.length > 0 ? ( ) : ( No Sessions )} {selectedSessionId && !selectedSessionId.startsWith('[Imported]') && ( {connectedSessions.includes(selectedSessionId) ? 'Connected' : 'Disconnected'} )}
{/* Content */}
{selectedSessionId ? ( <>
) : (
Please start Gemini CLI to begin debugging
)}
); } function TabButton({ active, onClick, label, t, }: { active: boolean; onClick: () => void; label: string; t: ThemeColors; }) { return (
{label}
); } // --- Console Components --- 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 ? '#f28b82' : isWarn ? '#fdd663' : 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 (
{icon}
{displayContent}
{needsCollapse && (
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 ? '−' : '+'}
)}
{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', })}
); } function ConsoleView({ logs, t }: { logs: ConsoleLog[]; t: ThemeColors }) { const bottomRef = useRef(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs.length]); if (logs.length === 0) { return (
No console logs in this session
); } return (
{logs.map((log) => ( ))}
); } // --- Network Components --- function NetworkView({ logs, t, isDark, }: { logs: NetworkLog[]; t: ThemeColors; isDark: boolean; }) { const [selectedId, setSelectedId] = useState(null); const [filter, setFilter] = useState(''); const [groupByDomain, setGroupByDomain] = useState(true); const [expandedGroups, setExpandedGroups] = useState>( {}, ); 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 = {}; 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 { /* ignore */ } 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) { // Collapse play.googleapis.com by default next[key] = !key.includes('play.googleapis.com'); } }); return next; }); } }, [groupedLogs]); const toggleGroup = (key: string) => { setExpandedGroups((prev) => ({ ...prev, [key]: !prev[key] })); }; // --- Context Menu --- (reserved for future actions) 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 { /* ignore */ } } const isSelected = log.id === selectedId; return (
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', }} >
{log.method} {name} {isPending ? '⏳' : status}
); }; if (logs.length === 0) { return (
No network activity in this session
); } return (
{/* List */}
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', }} />
{groupByDomain && groupedLogs ? Object.keys(groupedLogs).map((groupKey) => (
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', }} > {expandedGroups[groupKey] ? '▼' : '▶'} {groupKey} {groupedLogs[groupKey].length}
{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); })}
)) : filteredLogs.map((log) => renderLogItem(log))}
{/* Resizer */}
{/* Detail */}
{selectedLog ? ( ) : (
Select a request to view details
)}
); } type Tab = 'headers' | 'payload' | 'response'; function NetworkDetail({ log, t }: { log: NetworkLog; t: ThemeColors }) { const [activeTab, setActiveTab] = useState('headers'); const status = log.response ? log.pending ? '⏳' : log.response.status : log.error ? 'Error' : '⏳'; return (
{log.url}
{log.method} {status} {new Date(log.timestamp).toLocaleTimeString()} {log.response && ( <> {log.response.durationMs}ms )}
{(['headers', 'payload', 'response'] as const).map((tab) => (
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}
))}
{activeTab === 'headers' && (
{log.error && ( )}
{log.response ? ( ) : ( (no response yet) )}
)} {activeTab === 'payload' && } {activeTab === 'response' && ( )}
); } function Section({ title, children, t, }: { title: string; children: React.ReactNode; t: ThemeColors; }) { const [collapsed, setCollapsed] = useState(false); return (
setCollapsed(!collapsed)} style={{ padding: '8px 12px', background: t.bgSecondary, fontWeight: 'bold', fontSize: '11px', cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center', gap: '8px', }} > {collapsed ? '▶' : '▼'} {title}
{!collapsed && (
{children}
)}
); } function Pair({ k, v, color, t, }: { k: string; v: string; color?: string; t: ThemeColors; }) { return (
{k}:
{v}
); } function HeadersMap({ headers, t, }: { headers: Record | undefined; t: ThemeColors; }) { if (!headers) return
(none)
; return ( <> {Object.entries(headers).map(([k, v]) => ( ))} ); } 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(() => { // Clipboard API unavailable — silently ignore }); }; 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 (
(No content)
); const iconBtn = { background: 'none', border: 'none', cursor: 'pointer', color: t.textSecondary, padding: '2px', borderRadius: '4px', display: 'flex', alignItems: 'center', } as const; return (
{(['json', 'raw'] as const).map((m) => ( ))}
{hasChunks && mode === 'raw' ? (
{chunks.map((chunk, i) => (
[ {new Date(chunk.timestamp).toLocaleTimeString('en-US', { hour12: false, })} .{String(chunk.timestamp % 1000).padStart(3, '0')}]
                  {chunk.data}
                
))}
) : mode === 'raw' ? (
            {safeContent}
          
) : ( )}
); } 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 (
{chunks.map((chunk) => (
CHUNK {chunk.index}
))}
); } } return ; } function tryParse(str: string) { try { return JSON.parse(str); } catch { return str; } } interface JsonLine { text: string; foldStart: boolean; foldEnd: number; // -1 if not a fold start closingBracket: string; // '}' or ']' for fold starts } 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: '', })); // Match opening brackets to closing brackets using a stack 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 === '{' ? '}' : ']' }); } // Check for closing bracket (with or without trailing comma) 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; } // Tokenize a JSON line for syntax highlighting 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) { // Push any unmatched text before this token (whitespace/other) if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } const [full, key, str, num, bool, nul] = match; if (key) { parts.push( {full} , ); } else if (str) { // Unescape JSON string escapes so \n renders as actual newlines. // Use JSON.parse to handle all escape sequences correctly in one pass // (manual chained .replace() can double-unescape e.g. \\n → \). 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( {full} , ); } else { const indent = ' '.repeat(match.index + 1); parts.push( , ); } } else if (num) { parts.push( {full} , ); } else if (bool) { parts.push( {full} , ); } else if (nul) { parts.push( {full} , ); } else { // punctuation parts.push({full}); } lastIndex = match.index + full.length; } // Remaining text 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 ( "{displayLines[0]} {displayLines.slice(1).map((sl, si) => ( {'\n'} {indent} {sl} ))} {needsTruncation && ( <> {'\n'} {indent} setExpanded(!expanded)} style={{ color: t.accent, cursor: 'pointer', fontStyle: 'italic', userSelect: 'none', }} > {expanded ? '▲ collapse' : `... ${lines.length - STRING_LINE_THRESHOLD} more lines`} )} " ); } function CodeView({ data, t }: { data: unknown; t: ThemeColors }) { const lines = useMemo(() => jsonToLines(data), [data]); const [collapsed, setCollapsed] = useState>(() => new Set()); const contentRef = useRef(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); } } }; // Build visible lines, skipping folded regions 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) { // Show the opening line with collapsed indicator const indent = line.text.match(/^(\s*)/)?.[1] || ''; const trimmed = line.text.trimStart(); visibleLines.push({ index: i, content: ( <> {indent.length > 0 && {indent}} {highlightLine(trimmed, t)} {' '} ... {line.closingBracket} ), foldable: true, isCollapsed: true, }); // Skip to the line after foldEnd i = line.foldEnd + 1; } else { visibleLines.push({ index: i, content: highlightLine(line.text, t), foldable: line.foldStart, isCollapsed: false, }); i++; } } return (
{visibleLines.map((vl) => ( {/* Gutter cell */}
toggleFold(vl.index) : undefined} > {vl.foldable ? ( {vl.isCollapsed ? '▶' : '▼'} ) : ( '' )}
{/* Content cell */}
{vl.content}
))}
); }