rn-backstage 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +80 -0
  2. package/lib/commonjs/Backstage.js +130 -0
  3. package/lib/commonjs/Backstage.js.map +1 -0
  4. package/lib/commonjs/components/BackstagePanel.js +192 -0
  5. package/lib/commonjs/components/BackstagePanel.js.map +1 -0
  6. package/lib/commonjs/components/FloatingPill.js +147 -0
  7. package/lib/commonjs/components/FloatingPill.js.map +1 -0
  8. package/lib/commonjs/components/InfoTab.js +260 -0
  9. package/lib/commonjs/components/InfoTab.js.map +1 -0
  10. package/lib/commonjs/components/JsonTreeView.js +245 -0
  11. package/lib/commonjs/components/JsonTreeView.js.map +1 -0
  12. package/lib/commonjs/components/LogItem.js +191 -0
  13. package/lib/commonjs/components/LogItem.js.map +1 -0
  14. package/lib/commonjs/components/LogsTab.js +235 -0
  15. package/lib/commonjs/components/LogsTab.js.map +1 -0
  16. package/lib/commonjs/components/TabBar.js +132 -0
  17. package/lib/commonjs/components/TabBar.js.map +1 -0
  18. package/lib/commonjs/constants.js +89 -0
  19. package/lib/commonjs/constants.js.map +1 -0
  20. package/lib/commonjs/index.js +20 -0
  21. package/lib/commonjs/index.js.map +1 -0
  22. package/lib/commonjs/log-interceptor.js +114 -0
  23. package/lib/commonjs/log-interceptor.js.map +1 -0
  24. package/lib/commonjs/package.json +1 -0
  25. package/lib/commonjs/types.js +21 -0
  26. package/lib/commonjs/types.js.map +1 -0
  27. package/lib/commonjs/utils/formatTimestamp.js +26 -0
  28. package/lib/commonjs/utils/formatTimestamp.js.map +1 -0
  29. package/lib/commonjs/utils/stringify.js +78 -0
  30. package/lib/commonjs/utils/stringify.js.map +1 -0
  31. package/lib/module/Backstage.js +125 -0
  32. package/lib/module/Backstage.js.map +1 -0
  33. package/lib/module/components/BackstagePanel.js +187 -0
  34. package/lib/module/components/BackstagePanel.js.map +1 -0
  35. package/lib/module/components/FloatingPill.js +142 -0
  36. package/lib/module/components/FloatingPill.js.map +1 -0
  37. package/lib/module/components/InfoTab.js +255 -0
  38. package/lib/module/components/InfoTab.js.map +1 -0
  39. package/lib/module/components/JsonTreeView.js +241 -0
  40. package/lib/module/components/JsonTreeView.js.map +1 -0
  41. package/lib/module/components/LogItem.js +186 -0
  42. package/lib/module/components/LogItem.js.map +1 -0
  43. package/lib/module/components/LogsTab.js +230 -0
  44. package/lib/module/components/LogsTab.js.map +1 -0
  45. package/lib/module/components/TabBar.js +127 -0
  46. package/lib/module/components/TabBar.js.map +1 -0
  47. package/lib/module/constants.js +85 -0
  48. package/lib/module/constants.js.map +1 -0
  49. package/lib/module/index.js +5 -0
  50. package/lib/module/index.js.map +1 -0
  51. package/lib/module/log-interceptor.js +108 -0
  52. package/lib/module/log-interceptor.js.map +1 -0
  53. package/lib/module/package.json +1 -0
  54. package/lib/module/types.js +25 -0
  55. package/lib/module/types.js.map +1 -0
  56. package/lib/module/utils/formatTimestamp.js +22 -0
  57. package/lib/module/utils/formatTimestamp.js.map +1 -0
  58. package/lib/module/utils/stringify.js +73 -0
  59. package/lib/module/utils/stringify.js.map +1 -0
  60. package/lib/typescript/Backstage.d.ts +4 -0
  61. package/lib/typescript/Backstage.d.ts.map +1 -0
  62. package/lib/typescript/components/BackstagePanel.d.ts +21 -0
  63. package/lib/typescript/components/BackstagePanel.d.ts.map +1 -0
  64. package/lib/typescript/components/FloatingPill.d.ts +13 -0
  65. package/lib/typescript/components/FloatingPill.d.ts.map +1 -0
  66. package/lib/typescript/components/InfoTab.d.ts +16 -0
  67. package/lib/typescript/components/InfoTab.d.ts.map +1 -0
  68. package/lib/typescript/components/JsonTreeView.d.ts +10 -0
  69. package/lib/typescript/components/JsonTreeView.d.ts.map +1 -0
  70. package/lib/typescript/components/LogItem.d.ts +10 -0
  71. package/lib/typescript/components/LogItem.d.ts.map +1 -0
  72. package/lib/typescript/components/LogsTab.d.ts +11 -0
  73. package/lib/typescript/components/LogsTab.d.ts.map +1 -0
  74. package/lib/typescript/components/TabBar.d.ts +14 -0
  75. package/lib/typescript/components/TabBar.d.ts.map +1 -0
  76. package/lib/typescript/constants.d.ts +43 -0
  77. package/lib/typescript/constants.d.ts.map +1 -0
  78. package/lib/typescript/index.d.ts +4 -0
  79. package/lib/typescript/index.d.ts.map +1 -0
  80. package/lib/typescript/log-interceptor.d.ts +16 -0
  81. package/lib/typescript/log-interceptor.d.ts.map +1 -0
  82. package/lib/typescript/types.d.ts +110 -0
  83. package/lib/typescript/types.d.ts.map +1 -0
  84. package/lib/typescript/utils/formatTimestamp.d.ts +7 -0
  85. package/lib/typescript/utils/formatTimestamp.d.ts.map +1 -0
  86. package/lib/typescript/utils/stringify.d.ts +9 -0
  87. package/lib/typescript/utils/stringify.d.ts.map +1 -0
  88. package/package.json +91 -0
  89. package/src/Backstage.tsx +145 -0
  90. package/src/components/BackstagePanel.tsx +208 -0
  91. package/src/components/FloatingPill.tsx +150 -0
  92. package/src/components/InfoTab.tsx +264 -0
  93. package/src/components/JsonTreeView.tsx +259 -0
  94. package/src/components/LogItem.tsx +189 -0
  95. package/src/components/LogsTab.tsx +239 -0
  96. package/src/components/TabBar.tsx +150 -0
  97. package/src/constants.ts +82 -0
  98. package/src/index.ts +14 -0
  99. package/src/log-interceptor.ts +137 -0
  100. package/src/types.ts +147 -0
  101. package/src/utils/formatTimestamp.ts +22 -0
  102. package/src/utils/stringify.ts +90 -0
@@ -0,0 +1,259 @@
1
+ import React, { useCallback, useState } from 'react'
2
+ import {
3
+ LayoutAnimation,
4
+ Platform,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ UIManager,
9
+ View,
10
+ } from 'react-native'
11
+ import { DarkTheme, MonospaceFont } from '../constants'
12
+
13
+ // Enable LayoutAnimation on Android
14
+ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
15
+ UIManager.setLayoutAnimationEnabledExperimental(true)
16
+ }
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ interface JsonTreeViewProps {
21
+ data: unknown
22
+ hideRoot?: boolean
23
+ initialExpanded?: boolean
24
+ maxDepth?: number
25
+ }
26
+
27
+ interface JsonNodeProps {
28
+ keyName: string | null
29
+ value: unknown
30
+ depth: number
31
+ maxDepth: number
32
+ isLast: boolean
33
+ hideKey?: boolean
34
+ }
35
+
36
+ // ─── Color Mapping ───────────────────────────────────────────────────────────
37
+
38
+ function getValueColor(value: unknown): string {
39
+ if (value === null || value === undefined) return DarkTheme.textMuted
40
+ if (typeof value === 'string') return '#98C379' // green
41
+ if (typeof value === 'number') return '#61AFEF' // cyan
42
+ if (typeof value === 'boolean') return '#C678DD' // purple
43
+ return DarkTheme.text
44
+ }
45
+
46
+ function getPreview(value: unknown): string {
47
+ if (Array.isArray(value)) {
48
+ return `[ ${value.length} item${value.length !== 1 ? 's' : ''} ]`
49
+ }
50
+ if (typeof value === 'object' && value !== null) {
51
+ const keys = Object.keys(value)
52
+ return `{ ${keys.length} key${keys.length !== 1 ? 's' : ''} }`
53
+ }
54
+ return ''
55
+ }
56
+
57
+ function formatValue(value: unknown): string {
58
+ if (value === null) return 'null'
59
+ if (value === undefined) return 'undefined'
60
+ if (typeof value === 'string') return `"${value}"`
61
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
62
+ if (typeof value === 'number') return String(value)
63
+ if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
64
+ if (typeof value === 'symbol') return value.toString()
65
+ return String(value)
66
+ }
67
+
68
+ function isExpandable(value: unknown): boolean {
69
+ return (typeof value === 'object' && value !== null) || Array.isArray(value)
70
+ }
71
+
72
+ // ─── JsonNode Component ──────────────────────────────────────────────────────
73
+
74
+ const JsonNode: React.FC<JsonNodeProps> = ({
75
+ keyName,
76
+ value,
77
+ depth,
78
+ maxDepth,
79
+ isLast,
80
+ hideKey = false,
81
+ }) => {
82
+ const [expanded, setExpanded] = useState(depth < 1)
83
+ const expandable = isExpandable(value)
84
+ const reachedMaxDepth = depth >= maxDepth
85
+
86
+ const toggleExpand = useCallback(() => {
87
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
88
+ setExpanded(prev => !prev)
89
+ }, [])
90
+
91
+ const indent = depth * 16
92
+
93
+ // Primitive value
94
+ if (!expandable || reachedMaxDepth) {
95
+ const displayValue = reachedMaxDepth && expandable ? getPreview(value) : formatValue(value)
96
+
97
+ return (
98
+ <View style={[styles.row, { paddingLeft: indent }]}>
99
+ {!hideKey && keyName !== null && <Text style={styles.key}>{`${keyName}: `}</Text>}
100
+ <Text
101
+ style={[
102
+ styles.value,
103
+ { color: reachedMaxDepth ? DarkTheme.textMuted : getValueColor(value) },
104
+ ]}
105
+ numberOfLines={3}
106
+ >
107
+ {displayValue}
108
+ </Text>
109
+ {!isLast && <Text style={styles.comma}>,</Text>}
110
+ </View>
111
+ )
112
+ }
113
+
114
+ // Expandable object/array
115
+ const entries = Array.isArray(value)
116
+ ? value.map((v, i) => [String(i), v] as const)
117
+ : Object.entries(value as Record<string, unknown>)
118
+
119
+ const bracketOpen = Array.isArray(value) ? '[' : '{'
120
+ const bracketClose = Array.isArray(value) ? ']' : '}'
121
+
122
+ return (
123
+ <View>
124
+ <TouchableOpacity
125
+ onPress={toggleExpand}
126
+ activeOpacity={0.7}
127
+ style={[styles.row, { paddingLeft: indent }]}
128
+ >
129
+ <Text style={styles.chevron}>{expanded ? '▼' : '▶'}</Text>
130
+ {!hideKey && keyName !== null && <Text style={styles.key}>{`${keyName}: `}</Text>}
131
+ <Text style={styles.bracket}>{bracketOpen}</Text>
132
+ {!expanded && (
133
+ <>
134
+ <Text style={styles.preview}>{` ${getPreview(value)} `}</Text>
135
+ <Text style={styles.bracket}>{bracketClose}</Text>
136
+ {!isLast && <Text style={styles.comma}>,</Text>}
137
+ </>
138
+ )}
139
+ </TouchableOpacity>
140
+
141
+ {expanded && (
142
+ <>
143
+ {entries.map(([k, v], i) => (
144
+ <JsonNode
145
+ key={`${k}_${i}`}
146
+ keyName={Array.isArray(value) ? String(i) : k}
147
+ value={v}
148
+ depth={depth + 1}
149
+ maxDepth={maxDepth}
150
+ isLast={i === entries.length - 1}
151
+ />
152
+ ))}
153
+ <View style={[styles.row, { paddingLeft: indent }]}>
154
+ <Text style={styles.bracket}>{bracketClose}</Text>
155
+ {!isLast && <Text style={styles.comma}>,</Text>}
156
+ </View>
157
+ </>
158
+ )}
159
+ </View>
160
+ )
161
+ }
162
+
163
+ // ─── Main Component ──────────────────────────────────────────────────────────
164
+
165
+ export const JsonTreeView: React.FC<JsonTreeViewProps> = ({
166
+ data,
167
+ hideRoot = false,
168
+ maxDepth = 10,
169
+ }) => {
170
+ if (data === null || data === undefined) {
171
+ return <Text style={[styles.value, { color: DarkTheme.textMuted }]}>{String(data)}</Text>
172
+ }
173
+
174
+ if (!isExpandable(data)) {
175
+ return <Text style={[styles.value, { color: getValueColor(data) }]}>{formatValue(data)}</Text>
176
+ }
177
+
178
+ if (hideRoot) {
179
+ const entries = Array.isArray(data)
180
+ ? data.map((v, i) => [String(i), v] as const)
181
+ : Object.entries(data as Record<string, unknown>)
182
+
183
+ return (
184
+ <View style={styles.container}>
185
+ {entries.map(([k, v], i) => (
186
+ <JsonNode
187
+ key={`${k}_${i}`}
188
+ keyName={k}
189
+ value={v}
190
+ depth={0}
191
+ maxDepth={maxDepth}
192
+ isLast={i === entries.length - 1}
193
+ />
194
+ ))}
195
+ </View>
196
+ )
197
+ }
198
+
199
+ return (
200
+ <View style={styles.container}>
201
+ <JsonNode
202
+ keyName={null}
203
+ value={data}
204
+ depth={0}
205
+ maxDepth={maxDepth}
206
+ isLast={true}
207
+ hideKey={true}
208
+ />
209
+ </View>
210
+ )
211
+ }
212
+
213
+ // ─── Styles ──────────────────────────────────────────────────────────────────
214
+
215
+ const styles = StyleSheet.create({
216
+ container: {
217
+ paddingVertical: 4,
218
+ },
219
+ row: {
220
+ flexDirection: 'row',
221
+ alignItems: 'flex-start',
222
+ paddingVertical: 2,
223
+ flexWrap: 'wrap',
224
+ },
225
+ chevron: {
226
+ fontFamily: MonospaceFont,
227
+ fontSize: 10,
228
+ color: DarkTheme.textMuted,
229
+ marginRight: 6,
230
+ marginTop: 2,
231
+ width: 12,
232
+ },
233
+ key: {
234
+ fontFamily: MonospaceFont,
235
+ fontSize: 13,
236
+ color: '#E06C75', // red-ish for keys
237
+ },
238
+ value: {
239
+ fontFamily: MonospaceFont,
240
+ fontSize: 13,
241
+ flexShrink: 1,
242
+ },
243
+ bracket: {
244
+ fontFamily: MonospaceFont,
245
+ fontSize: 13,
246
+ color: DarkTheme.textSecondary,
247
+ },
248
+ preview: {
249
+ fontFamily: MonospaceFont,
250
+ fontSize: 13,
251
+ color: DarkTheme.textMuted,
252
+ fontStyle: 'italic',
253
+ },
254
+ comma: {
255
+ fontFamily: MonospaceFont,
256
+ fontSize: 13,
257
+ color: DarkTheme.textMuted,
258
+ },
259
+ })
@@ -0,0 +1,189 @@
1
+ import React, { useCallback, useState } from 'react'
2
+ import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
3
+ import { DarkTheme, MonospaceFont } from '../constants'
4
+ import { LogLevel } from '../types'
5
+ import type { LogEntry, BackstageStyleOverrides } from '../types'
6
+ import { formatTimestamp } from '../utils/formatTimestamp'
7
+ import { JsonTreeView } from './JsonTreeView'
8
+
9
+ // ─── Types ───────────────────────────────────────────────────────────────────
10
+
11
+ interface LogItemProps {
12
+ item: LogEntry
13
+ onCopy?: (text: string) => void
14
+ styles?: BackstageStyleOverrides
15
+ }
16
+
17
+ // ─── Badge Colors ────────────────────────────────────────────────────────────
18
+
19
+ function getBadgeConfig(level: LogLevel): {
20
+ label: string
21
+ color: string
22
+ bgColor: string
23
+ rowBg: string
24
+ } {
25
+ switch (level) {
26
+ case LogLevel.error:
27
+ return {
28
+ label: 'ERROR',
29
+ color: DarkTheme.error,
30
+ bgColor: DarkTheme.errorDim,
31
+ rowBg: 'rgba(255, 77, 106, 0.06)',
32
+ }
33
+ case LogLevel.warn:
34
+ return {
35
+ label: 'WARN',
36
+ color: DarkTheme.warning,
37
+ bgColor: DarkTheme.warningDim,
38
+ rowBg: 'rgba(255, 178, 36, 0.06)',
39
+ }
40
+ case LogLevel.info:
41
+ return {
42
+ label: 'INFO',
43
+ color: DarkTheme.info,
44
+ bgColor: DarkTheme.infoDim,
45
+ rowBg: 'transparent',
46
+ }
47
+ case LogLevel.debug:
48
+ return {
49
+ label: 'DEBUG',
50
+ color: DarkTheme.debugColor,
51
+ bgColor: DarkTheme.debugDim,
52
+ rowBg: 'transparent',
53
+ }
54
+ default:
55
+ return {
56
+ label: 'LOG',
57
+ color: DarkTheme.textSecondary,
58
+ bgColor: 'rgba(160, 160, 176, 0.1)',
59
+ rowBg: 'transparent',
60
+ }
61
+ }
62
+ }
63
+
64
+ // ─── Component ───────────────────────────────────────────────────────────────
65
+
66
+ export const LogItem: React.FC<LogItemProps> = React.memo(
67
+ ({ item, onCopy, styles: propStyles }) => {
68
+ const [expanded, setExpanded] = useState(false)
69
+ const badge = getBadgeConfig(item.level)
70
+ const hasData = item.data !== undefined
71
+
72
+ const toggleExpand = useCallback(() => {
73
+ setExpanded(prev => !prev)
74
+ }, [])
75
+
76
+ const handleCopy = useCallback(() => {
77
+ if (onCopy) {
78
+ const text = `[${badge.label}] ${formatTimestamp(item.timestamp)} ${item.message}`
79
+ onCopy(text)
80
+ }
81
+ }, [item, badge.label, onCopy])
82
+
83
+ return (
84
+ <TouchableOpacity
85
+ style={[styles.container, { backgroundColor: badge.rowBg }]}
86
+ onPress={hasData ? toggleExpand : undefined}
87
+ onLongPress={handleCopy}
88
+ activeOpacity={hasData ? 0.7 : 1}
89
+ >
90
+ {/* Header row: badge + timestamp + copy */}
91
+ <View style={styles.headerRow}>
92
+ <View style={[styles.badge, { backgroundColor: badge.bgColor }]}>
93
+ <Text style={[styles.badgeText, { color: badge.color }]}>{badge.label}</Text>
94
+ </View>
95
+ <Text style={[styles.timestamp, propStyles?.logTimestampStyle]}>
96
+ {formatTimestamp(item.timestamp)}
97
+ </Text>
98
+ {onCopy && (
99
+ <TouchableOpacity
100
+ style={styles.copyButton}
101
+ onPress={handleCopy}
102
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
103
+ >
104
+ <Text style={styles.copyButtonText}>⧉</Text>
105
+ </TouchableOpacity>
106
+ )}
107
+ </View>
108
+
109
+ {/* Message */}
110
+ <Text
111
+ style={[styles.message, propStyles?.logMessageStyle]}
112
+ numberOfLines={expanded ? undefined : 3}
113
+ >
114
+ {item.message}
115
+ </Text>
116
+
117
+ {/* Expandable data */}
118
+ {expanded && hasData && (
119
+ <View style={styles.dataContainer}>
120
+ <JsonTreeView data={item.data} hideRoot maxDepth={5} />
121
+ </View>
122
+ )}
123
+
124
+ {/* Expand hint */}
125
+ {hasData && !expanded && <Text style={styles.expandHint}>tap to expand ▾</Text>}
126
+ </TouchableOpacity>
127
+ )
128
+ },
129
+ )
130
+
131
+ // ─── Styles ──────────────────────────────────────────────────────────────────
132
+
133
+ const styles = StyleSheet.create({
134
+ container: {
135
+ paddingHorizontal: 14,
136
+ paddingVertical: 10,
137
+ borderBottomWidth: 1,
138
+ borderBottomColor: DarkTheme.border,
139
+ },
140
+ headerRow: {
141
+ flexDirection: 'row',
142
+ alignItems: 'center',
143
+ marginBottom: 4,
144
+ },
145
+ badge: {
146
+ borderRadius: 4,
147
+ paddingHorizontal: 6,
148
+ paddingVertical: 2,
149
+ marginRight: 8,
150
+ },
151
+ badgeText: {
152
+ fontFamily: MonospaceFont,
153
+ fontSize: 10,
154
+ fontWeight: '800',
155
+ letterSpacing: 0.5,
156
+ },
157
+ timestamp: {
158
+ fontFamily: MonospaceFont,
159
+ fontSize: 11,
160
+ color: DarkTheme.textMuted,
161
+ flex: 1,
162
+ },
163
+ copyButton: {
164
+ padding: 4,
165
+ },
166
+ copyButtonText: {
167
+ fontSize: 16,
168
+ color: DarkTheme.textMuted,
169
+ },
170
+ message: {
171
+ fontFamily: MonospaceFont,
172
+ fontSize: 12,
173
+ color: DarkTheme.text,
174
+ lineHeight: 18,
175
+ },
176
+ dataContainer: {
177
+ marginTop: 8,
178
+ paddingTop: 8,
179
+ borderTopWidth: 1,
180
+ borderTopColor: DarkTheme.border,
181
+ },
182
+ expandHint: {
183
+ fontFamily: MonospaceFont,
184
+ fontSize: 10,
185
+ color: DarkTheme.textMuted,
186
+ marginTop: 4,
187
+ fontStyle: 'italic',
188
+ },
189
+ })
@@ -0,0 +1,239 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import { FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'
3
+ import { DarkTheme, MonospaceFont, TestIDs } from '../constants'
4
+ import type { LogEntry, BackstageStyleOverrides } from '../types'
5
+ import { LogItem } from './LogItem'
6
+ import { formatTimestamp } from '../utils/formatTimestamp'
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ interface LogsTabProps {
11
+ logs: LogEntry[]
12
+ onRefresh: () => void
13
+ onCopyLogs?: (logs: string) => void
14
+ styles?: BackstageStyleOverrides
15
+ }
16
+
17
+ // ─── Component ───────────────────────────────────────────────────────────────
18
+
19
+ export const LogsTab: React.FC<LogsTabProps> = ({
20
+ logs,
21
+ onRefresh,
22
+ onCopyLogs,
23
+ styles: propStyles,
24
+ }) => {
25
+ const [searchText, setSearchText] = useState('')
26
+
27
+ // Filter logs based on search text
28
+ const filteredLogs = useMemo(() => {
29
+ if (!searchText.trim()) return logs
30
+
31
+ const query = searchText.toLowerCase()
32
+ return logs.filter(log => {
33
+ return log.message.toLowerCase().includes(query) || log.level.toLowerCase().includes(query)
34
+ })
35
+ }, [logs, searchText])
36
+
37
+ // Copy all visible logs to text
38
+ const handleCopyAll = useCallback(() => {
39
+ if (!onCopyLogs) return
40
+
41
+ const text = filteredLogs
42
+ .map(log => `[${log.level.toUpperCase()}] ${formatTimestamp(log.timestamp)} ${log.message}`)
43
+ .join('\n')
44
+ onCopyLogs(text)
45
+ }, [filteredLogs, onCopyLogs])
46
+
47
+ // Copy individual log
48
+ const handleCopyItem = useCallback(
49
+ (text: string) => {
50
+ if (onCopyLogs) {
51
+ onCopyLogs(text)
52
+ }
53
+ },
54
+ [onCopyLogs],
55
+ )
56
+
57
+ // Render log item
58
+ const renderItem = useCallback(
59
+ ({ item }: { item: LogEntry }) => (
60
+ <LogItem item={item} onCopy={onCopyLogs ? handleCopyItem : undefined} styles={propStyles} />
61
+ ),
62
+ [handleCopyItem, onCopyLogs, propStyles],
63
+ )
64
+
65
+ const keyExtractor = useCallback((item: LogEntry) => item.id, [])
66
+
67
+ return (
68
+ <View style={styles.container}>
69
+ {/* Search Bar */}
70
+ <View style={styles.searchContainer}>
71
+ <View style={styles.searchInputWrapper}>
72
+ <Text style={styles.searchIcon}>⌕</Text>
73
+ <TextInput
74
+ testID={TestIDs.logsTab.searchInput}
75
+ style={styles.searchInput}
76
+ placeholder="Filter logs..."
77
+ placeholderTextColor={DarkTheme.textMuted}
78
+ value={searchText}
79
+ onChangeText={setSearchText}
80
+ autoCapitalize="none"
81
+ autoCorrect={false}
82
+ clearButtonMode="while-editing"
83
+ returnKeyType="search"
84
+ />
85
+ </View>
86
+ {onCopyLogs && (
87
+ <TouchableOpacity
88
+ testID={TestIDs.logsTab.copyButton}
89
+ style={styles.copyAllButton}
90
+ onPress={handleCopyAll}
91
+ activeOpacity={0.7}
92
+ >
93
+ <Text style={styles.copyAllText}>Copy All</Text>
94
+ </TouchableOpacity>
95
+ )}
96
+ </View>
97
+
98
+ {/* Log Count */}
99
+ <View style={styles.countBar}>
100
+ <Text style={styles.countText}>
101
+ {filteredLogs.length === logs.length
102
+ ? `${logs.length} log${logs.length !== 1 ? 's' : ''}`
103
+ : `${filteredLogs.length} of ${logs.length} logs`}
104
+ </Text>
105
+ <Text style={styles.pullHint}>↓ pull to refresh</Text>
106
+ </View>
107
+
108
+ {/* Log List */}
109
+ <FlatList
110
+ testID={TestIDs.logsTab.list}
111
+ data={filteredLogs}
112
+ renderItem={renderItem}
113
+ keyExtractor={keyExtractor}
114
+ refreshing={false}
115
+ onRefresh={onRefresh}
116
+ style={styles.list}
117
+ contentContainerStyle={filteredLogs.length === 0 ? styles.emptyContainer : undefined}
118
+ ListEmptyComponent={
119
+ <View style={styles.emptyState}>
120
+ <Text style={styles.emptyIcon}>📋</Text>
121
+ <Text style={styles.emptyTitle}>No logs yet</Text>
122
+ <Text style={styles.emptySubtitle}>Console output will appear here</Text>
123
+ </View>
124
+ }
125
+ // Perf optimizations
126
+ maxToRenderPerBatch={20}
127
+ windowSize={10}
128
+ initialNumToRender={20}
129
+ removeClippedSubviews={true}
130
+ getItemLayout={undefined}
131
+ />
132
+ </View>
133
+ )
134
+ }
135
+
136
+ // ─── Styles ──────────────────────────────────────────────────────────────────
137
+
138
+ const styles = StyleSheet.create({
139
+ container: {
140
+ flex: 1,
141
+ },
142
+ searchContainer: {
143
+ flexDirection: 'row',
144
+ alignItems: 'center',
145
+ paddingHorizontal: 14,
146
+ paddingVertical: 10,
147
+ gap: 10,
148
+ borderBottomWidth: 1,
149
+ borderBottomColor: DarkTheme.border,
150
+ },
151
+ searchInputWrapper: {
152
+ flex: 1,
153
+ flexDirection: 'row',
154
+ alignItems: 'center',
155
+ backgroundColor: DarkTheme.surfaceElevated,
156
+ borderRadius: 10,
157
+ borderWidth: 1,
158
+ borderColor: DarkTheme.border,
159
+ paddingHorizontal: 12,
160
+ height: 38,
161
+ },
162
+ searchIcon: {
163
+ fontSize: 16,
164
+ color: DarkTheme.textMuted,
165
+ marginRight: 8,
166
+ },
167
+ searchInput: {
168
+ flex: 1,
169
+ fontFamily: MonospaceFont,
170
+ fontSize: 13,
171
+ color: DarkTheme.text,
172
+ padding: 0,
173
+ },
174
+ copyAllButton: {
175
+ backgroundColor: DarkTheme.accentDim,
176
+ borderRadius: 8,
177
+ borderWidth: 1,
178
+ borderColor: DarkTheme.accent,
179
+ paddingHorizontal: 14,
180
+ height: 38,
181
+ justifyContent: 'center',
182
+ },
183
+ copyAllText: {
184
+ fontFamily: MonospaceFont,
185
+ fontSize: 12,
186
+ fontWeight: '700',
187
+ color: DarkTheme.accent,
188
+ letterSpacing: 0.5,
189
+ },
190
+ countBar: {
191
+ flexDirection: 'row',
192
+ justifyContent: 'space-between',
193
+ alignItems: 'center',
194
+ paddingHorizontal: 16,
195
+ paddingVertical: 6,
196
+ backgroundColor: DarkTheme.surface,
197
+ },
198
+ countText: {
199
+ fontFamily: MonospaceFont,
200
+ fontSize: 11,
201
+ color: DarkTheme.textMuted,
202
+ },
203
+ pullHint: {
204
+ fontFamily: MonospaceFont,
205
+ fontSize: 10,
206
+ color: DarkTheme.textMuted,
207
+ fontStyle: 'italic',
208
+ },
209
+ list: {
210
+ flex: 1,
211
+ },
212
+ emptyContainer: {
213
+ flex: 1,
214
+ justifyContent: 'center',
215
+ },
216
+ emptyState: {
217
+ alignItems: 'center',
218
+ justifyContent: 'center',
219
+ padding: 40,
220
+ },
221
+ emptyIcon: {
222
+ fontSize: 48,
223
+ marginBottom: 16,
224
+ opacity: 0.5,
225
+ },
226
+ emptyTitle: {
227
+ fontFamily: MonospaceFont,
228
+ fontSize: 16,
229
+ fontWeight: '700',
230
+ color: DarkTheme.textSecondary,
231
+ marginBottom: 8,
232
+ },
233
+ emptySubtitle: {
234
+ fontFamily: MonospaceFont,
235
+ fontSize: 13,
236
+ color: DarkTheme.textMuted,
237
+ textAlign: 'center',
238
+ },
239
+ })