Files
gemini-cli/packages/devtools/client/src/App.tsx

1876 lines
51 KiB
TypeScript
Raw Normal View History

/**
* @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<string | null>(
null,
);
const [importedLogs, setImportedLogs] = useState<{
network: NetworkLog[];
console: ConsoleLog[];
} | null>(null);
const [importedSessionId, setImportedSessionId] = useState<string | null>(
null,
);
// --- Theme Logic ---
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
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<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 {
// 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<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={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>
{/* Content */}
<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>
</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>
);
}
// --- 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 (
<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>
);
}
// --- Network Components ---
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 {
/* 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 (
<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(() => {
// 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 (
<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; // -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(
<span key={lastIndex} style={{ color: t.accent, fontWeight: 'bold' }}>
{full}
</span>,
);
} 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 → \<newline>).
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 {
// punctuation
parts.push(<span key={lastIndex}>{full}</span>);
}
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 (
<span style={{ color: '#81c995' }}>
&quot;{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>
</>
)}
&quot;
</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);
}
}
};
// 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 && <span>{indent}</span>}
{highlightLine(trimmed, t)}
<span style={{ color: t.textSecondary, fontStyle: 'italic' }}>
{' '}
... {line.closingBracket}
</span>
</>
),
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 (
<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>
);
}