import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { View, Text, FlatList, TouchableOpacity, TextInput, StyleSheet, ScrollView, } from 'react-native'; import type { PluginComponentProps, NetworkRequest } from '../../core/types'; import type { DebuggerPlugin } from '../../core/types'; import { NetworkInterceptor } from './NetworkInterceptor'; import { formatDuration, formatBytes, formatTimestamp, getStatusColor, getMethodColor, truncate, toCurl, copyToClipboard, safeStringify, shareText, } from '../../core/utils'; // ─── Network Inspector Plugin Component ───────────────────────────────────── const NetworkInspectorPanel: React.FC = ({ theme }) => { const [requests, setRequests] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedRequestId, setSelectedRequestId] = useState(null); const selectedRequest = useMemo( () => requests.find((r) => r.id === selectedRequestId) || null, [requests, selectedRequestId], ); const [activeDetailTab, setActiveDetailTab] = useState< 'headers' | 'request' | 'response' | 'timing' >('headers'); const [methodFilter, setMethodFilter] = useState(null); useEffect(() => { const unsubscribe = NetworkInterceptor.subscribe(setRequests); return unsubscribe; }, []); const filteredRequests = useMemo(() => { let filtered = requests; if (searchQuery) { const q = searchQuery.toLowerCase(); filtered = filtered.filter( (r) => r.url.toLowerCase().includes(q) || r.method.toLowerCase().includes(q) || r.gqlOperation?.toLowerCase().includes(q) || String(r.status).includes(q), ); } if (methodFilter) { filtered = filtered.filter((r) => r.method === methodFilter); } return filtered; }, [requests, searchQuery, methodFilter]); const stats = useMemo(() => { const total = requests.length; const errors = requests.filter((r) => r.isError).length; const pending = requests.filter((r) => !r.endTime).length; return { total, errors, pending }; }, [requests]); const handleClear = useCallback(() => { NetworkInterceptor.clear(); setSelectedRequestId(null); }, []); const handleCopyCurl = useCallback((req: NetworkRequest) => { copyToClipboard(toCurl(req)); }, []); const handleShareRequest = useCallback((req: NetworkRequest) => { const data = safeStringify({ url: req.url, method: req.method, status: req.status, duration: req.duration, requestHeaders: req.requestHeaders, responseHeaders: req.responseHeaders, requestBody: req.requestBody, responseBody: req.responseBody, }); shareText(data, `${req.method} ${req.url}`); }, []); if (selectedRequest) { return ( setSelectedRequestId(null)} onCopyCurl={handleCopyCurl} onShare={handleShareRequest} /> ); } return ( {/* Stats Bar */} {stats.total} requests {stats.errors > 0 && ( {stats.errors} errors )} {stats.pending > 0 && ( {stats.pending} pending )} Clear {/* Search */} {/* Method Filters */} {['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((method) => ( setMethodFilter(method === 'ALL' ? null : method)} activeOpacity={0.7} > {method} ))} {/* Request List */} item.id} renderItem={({ item }) => ( setSelectedRequestId(item.id)} /> )} ListEmptyComponent={ {searchQuery ? 'No matching requests' : 'No network requests captured yet'} } initialNumToRender={20} maxToRenderPerBatch={10} windowSize={10} /> ); }; // ─── Request Row ──────────────────────────────────────────────────────────── interface RequestRowProps { request: NetworkRequest; theme: PluginComponentProps['theme']; onPress: () => void; } const RequestRow: React.FC = React.memo(({ request, theme, onPress }) => { const urlPath = useMemo(() => { try { const parsed = new URL(request.url); return parsed.pathname + parsed.search; } catch { return request.url; } }, [request.url]); return ( {request.method} {request.status ?? '...'} {request.gqlOperation ? `[${request.gqlType}] ${request.gqlOperation}` : truncate(urlPath, 60)} {formatDuration(request.duration)} {formatBytes(request.responseSize)} {formatTimestamp(request.startTime)} ); }); // ─── Request Detail ───────────────────────────────────────────────────────── interface RequestDetailProps { request: NetworkRequest; theme: PluginComponentProps['theme']; activeTab: 'headers' | 'request' | 'response' | 'timing'; onTabChange: (tab: 'headers' | 'request' | 'response' | 'timing') => void; onBack: () => void; onCopyCurl: (req: NetworkRequest) => void; onShare: (req: NetworkRequest) => void; } const RequestDetail: React.FC = ({ request, theme, activeTab, onTabChange, onBack, onCopyCurl, onShare, }) => { const tabs = ['headers', 'request', 'response', 'timing'] as const; return ( {/* Detail Header */} ← Back onCopyCurl(request)} style={[styles.actionButton, { backgroundColor: theme.surfaceAlt }]} activeOpacity={0.7} > cURL onShare(request)} style={[styles.actionButton, { backgroundColor: theme.surfaceAlt }]} activeOpacity={0.7} > Share {/* URL Display */} {request.method} {request.url} {/* Detail Tabs */} {tabs.map((tab) => ( onTabChange(tab)} activeOpacity={0.7} > {tab.charAt(0).toUpperCase() + tab.slice(1)} ))} {/* Tab Content */} {activeTab === 'headers' && } {activeTab === 'request' && ( )} {activeTab === 'response' && ( )} {activeTab === 'timing' && } ); }; // ─── Sub-views ────────────────────────────────────────────────────────────── const HeadersView: React.FC<{ request: NetworkRequest; theme: PluginComponentProps['theme'] }> = ({ request, theme, }) => ( Request Headers {request.requestHeaders && Object.keys(request.requestHeaders).length > 0 ? ( Object.entries(request.requestHeaders).map(([key, value]) => ( {key} {value} )) ) : ( No headers )} Response Headers {request.responseHeaders && Object.keys(request.responseHeaders).length > 0 ? ( Object.entries(request.responseHeaders).map(([key, value]) => ( {key} {value} )) ) : ( {request.endTime ? 'No headers' : 'Pending...'} )} ); const BodyView: React.FC<{ body: string | undefined; theme: PluginComponentProps['theme']; label: string; isPending?: boolean; }> = ({ body, theme, label, isPending }) => { const formatted = useMemo(() => { if (!body) return null; try { const parsed = JSON.parse(body); return JSON.stringify(parsed, null, 2); } catch { return body; } }, [body]); return ( {label} {formatted && ( copyToClipboard(formatted)} activeOpacity={0.7}> Copy )} {formatted ? ( {formatted} ) : ( {isPending ? 'Pending...' : 'No body'} )} ); }; const TimingView: React.FC<{ request: NetworkRequest; theme: PluginComponentProps['theme'] }> = ({ request, theme, }) => ( Started {formatTimestamp(request.startTime)} {request.endTime && ( Completed {formatTimestamp(request.endTime)} )} Duration 3000 ? theme.warning : theme.success }, ]} > {formatDuration(request.duration)} Request Size {formatBytes(request.requestSize)} Response Size {formatBytes(request.responseSize)} ); // ─── Styles ───────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ container: { flex: 1 }, statsBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, }, statsRow: { flexDirection: 'row', gap: 12 }, statText: { fontSize: 12, fontWeight: '600' }, clearButton: { fontSize: 13, fontWeight: '700' }, searchContainer: { paddingHorizontal: 16, paddingBottom: 12 }, searchInput: { height: 36, borderRadius: 6, // GitHub style paddingHorizontal: 12, fontSize: 13, borderWidth: 1, }, filterContainer: { flexGrow: 0, flexShrink: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#30363d', marginBottom: 8, }, filterContent: { paddingHorizontal: 16, paddingVertical: 12, gap: 8, alignItems: 'center', }, filterChip: { paddingHorizontal: 14, height: 32, borderRadius: 6, // GitHub style borderWidth: 1, justifyContent: 'center', alignItems: 'center', }, filterChipText: { fontSize: 13, fontWeight: '500' }, // Removed massive letter spacing and adjusted weight requestRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, }, requestRowLeft: { flex: 1, marginRight: 8 }, requestRowRight: { alignItems: 'flex-end' }, requestMethodContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 2 }, requestMethod: { fontSize: 11, fontWeight: '800', letterSpacing: 0.5 }, requestStatus: { fontSize: 11, fontWeight: '700' }, requestUrl: { fontSize: 12 }, requestDuration: { fontSize: 11, fontWeight: '600' }, requestSize: { fontSize: 10, marginTop: 1 }, requestTime: { fontSize: 10, marginTop: 1 }, emptyContainer: { alignItems: 'center', paddingVertical: 40 }, emptyText: { fontSize: 13 }, detailHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, backButton: { fontSize: 14, fontWeight: '600' }, detailActions: { flexDirection: 'row', gap: 8 }, actionButton: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6 }, actionButtonText: { fontSize: 12, fontWeight: '500' }, urlDisplay: { padding: 12, flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, urlMethod: { fontSize: 12, fontWeight: '800' }, urlText: { fontSize: 11, flex: 1 }, detailTabBar: { flexDirection: 'row' }, detailTab: { flex: 1, alignItems: 'center', paddingVertical: 10, borderBottomWidth: 2 }, detailTabText: { fontSize: 12, fontWeight: '600' }, detailContent: { flex: 1 }, sectionContainer: { padding: 12 }, sectionTitle: { fontSize: 14, fontWeight: '700', marginBottom: 8 }, sectionHeaderRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, }, copyButton: { fontSize: 12, fontWeight: '600' }, headerRow: { flexDirection: 'row', paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, }, headerKey: { fontSize: 11, fontWeight: '600', width: 120, marginRight: 8 }, headerValue: { fontSize: 11, flex: 1 }, codeBlock: { padding: 12, borderRadius: 6 }, codeText: { fontSize: 11, fontFamily: 'monospace', lineHeight: 18 }, timingRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, timingLabel: { fontSize: 13 }, timingValue: { fontSize: 13, fontWeight: '600' }, }); // ─── Plugin Factory ───────────────────────────────────────────────────────── export function createNetworkInspectorPlugin(): DebuggerPlugin { return { id: 'network-inspector', name: 'Network', icon: '🌐', component: NetworkInspectorPanel, order: 10, onInit: () => NetworkInterceptor.start(), onDestroy: () => NetworkInterceptor.stop(), }; }