rn-backstage 1.4.2 → 1.4.3

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