import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, FlatList, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; import type { PluginComponentProps, CrashEntry, DebuggerPlugin } from '../../core/types'; import { generateId, formatTimestamp, copyToClipboard, shareText } from '../../core/utils'; // ─── Crash Store ──────────────────────────────────────────────────────────── type CrashListener = (entries: CrashEntry[]) => void; class CrashStoreClass { private entries: CrashEntry[] = []; private listeners: Set = new Set(); private isActive = false; private maxEntries = 100; private originalHandler: ((error: Error, isFatal?: boolean) => void) | null = null; start(): void { if (this.isActive) return; this.isActive = true; // Capture unhandled JS errors const ErrorUtils = ( globalThis as unknown as { ErrorUtils?: { getGlobalHandler: () => (error: Error, isFatal?: boolean) => void; setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => void; }; } ).ErrorUtils; if (ErrorUtils) { this.originalHandler = ErrorUtils.getGlobalHandler(); ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => { this.addEntry({ id: generateId(), timestamp: Date.now(), message: error.message, stack: error.stack, isFatal: isFatal ?? false, }); // Forward to original handler if (this.originalHandler) { this.originalHandler(error, isFatal); } }); } // Capture unhandled promise rejections const tracking = require('promise/setimmediate/rejection-tracking'); if (tracking) { try { tracking.enable({ allRejections: true, onUnhandled: (_id: number, error: Error) => { this.addEntry({ id: generateId(), timestamp: Date.now(), message: error?.message || 'Unhandled Promise Rejection', stack: error?.stack, isFatal: false, }); }, }); } catch { // Rejection tracking may not be available } } } stop(): void { if (!this.isActive) return; this.isActive = false; const ErrorUtils = ( globalThis as unknown as { ErrorUtils?: { setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => void; }; } ).ErrorUtils; if (ErrorUtils && this.originalHandler) { ErrorUtils.setGlobalHandler(this.originalHandler); this.originalHandler = null; } } addEntry(entry: CrashEntry): void { this.entries = [entry, ...this.entries].slice(0, this.maxEntries); this.notify(); } getAll(): CrashEntry[] { return [...this.entries]; } clear(): void { this.entries = []; this.notify(); } subscribe(listener: CrashListener): () => void { this.listeners.add(listener); listener(this.entries); return () => this.listeners.delete(listener); } private notify(): void { const snapshot = this.entries; for (const l of this.listeners) { try { l(snapshot); } catch { /* ignore */ } } } } const crashStore = new CrashStoreClass(); // ─── Crash Reporter Panel ─────────────────────────────────────────────────── const CrashReporterPanel: React.FC = ({ theme }) => { const [entries, setEntries] = useState([]); const [selectedEntry, setSelectedEntry] = useState(null); useEffect(() => { const unsub = crashStore.subscribe(setEntries); return unsub; }, []); const handleClear = useCallback(() => { crashStore.clear(); setSelectedEntry(null); }, []); if (selectedEntry) { return ( setSelectedEntry(null)} activeOpacity={0.7}> ← Back copyToClipboard(`${selectedEntry.message}\n\n${selectedEntry.stack || ''}`) } activeOpacity={0.7} > Copy shareText( `${selectedEntry.message}\n\n${selectedEntry.stack || ''}`, 'Crash Report', ) } activeOpacity={0.7} > Share {selectedEntry.isFatal ? 'FATAL' : 'NON-FATAL'} {formatTimestamp(selectedEntry.timestamp)} {selectedEntry.message} {selectedEntry.stack && ( {selectedEntry.stack} )} {selectedEntry.componentStack && ( Component Stack {selectedEntry.componentStack} )} ); } return ( {entries.length > 0 ? `${entries.length} crashes` : 'No crashes 🎉'} {entries.length > 0 && ( Clear )} item.id} renderItem={({ item }) => ( setSelectedEntry(item)} activeOpacity={0.7} > {item.message} {formatTimestamp(item.timestamp)} )} ListEmptyComponent={ No crashes captured } /> ); }; const styles = StyleSheet.create({ container: { flex: 1 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, headerTitle: { fontSize: 14, fontWeight: '700' }, clearBtn: { fontSize: 13, fontWeight: '700' }, backBtn: { fontSize: 14, fontWeight: '600' }, headerActions: { flexDirection: 'row', gap: 12 }, actionText: { fontSize: 12, fontWeight: '600' }, crashRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, fatalDot: { width: 8, height: 8, borderRadius: 4, marginRight: 10 }, crashInfo: { flex: 1 }, crashMsg: { fontSize: 13, fontWeight: '500' }, crashTimestamp: { fontSize: 10, marginTop: 2 }, emptyContainer: { alignItems: 'center', paddingVertical: 60 }, emptyIcon: { fontSize: 48, marginBottom: 12 }, emptyText: { fontSize: 13 }, detailContent: { flex: 1, padding: 12 }, crashBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 6, marginBottom: 8, }, crashBadgeText: { fontSize: 10, fontWeight: '800', color: '#FFF', letterSpacing: 1 }, crashTime: { fontSize: 11, marginBottom: 8 }, crashMessage: { fontSize: 16, fontWeight: '700', marginBottom: 16 }, stackBlock: { padding: 12, borderRadius: 8 }, stackLabel: { fontSize: 11, fontWeight: '700', marginBottom: 4, textTransform: 'uppercase' }, stackText: { fontSize: 11, fontFamily: 'monospace', lineHeight: 18 }, }); export function createCrashReporterPlugin(): DebuggerPlugin { return { id: 'crash-reporter', name: 'Crashes', icon: '💥', component: CrashReporterPanel, order: 100, }; }