react-native-network-lens 0.0.0-stage → 0.1.1

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 (180) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +208 -2
  3. package/lib/module/backHandler.js +31 -0
  4. package/lib/module/backHandler.js.map +1 -0
  5. package/lib/module/components/BodyViewer.js +213 -0
  6. package/lib/module/components/BodyViewer.js.map +1 -0
  7. package/lib/module/components/CodeList.js +104 -0
  8. package/lib/module/components/CodeList.js.map +1 -0
  9. package/lib/module/components/JsonTree/JsonTreeView.js +206 -0
  10. package/lib/module/components/JsonTree/JsonTreeView.js.map +1 -0
  11. package/lib/module/components/JsonTree/model.js +196 -0
  12. package/lib/module/components/JsonTree/model.js.map +1 -0
  13. package/lib/module/components/NetworkLens.js +110 -0
  14. package/lib/module/components/NetworkLens.js.map +1 -0
  15. package/lib/module/components/NetworkLensOverlay.js +158 -0
  16. package/lib/module/components/NetworkLensOverlay.js.map +1 -0
  17. package/lib/module/components/RawTextView.js +119 -0
  18. package/lib/module/components/RawTextView.js.map +1 -0
  19. package/lib/module/components/RequestDetails/RequestDetails.js +256 -0
  20. package/lib/module/components/RequestDetails/RequestDetails.js.map +1 -0
  21. package/lib/module/components/RequestList/FilterBar.js +138 -0
  22. package/lib/module/components/RequestList/FilterBar.js.map +1 -0
  23. package/lib/module/components/RequestList/RequestList.js +163 -0
  24. package/lib/module/components/RequestList/RequestList.js.map +1 -0
  25. package/lib/module/components/RequestList/RequestRow.js +161 -0
  26. package/lib/module/components/RequestList/RequestRow.js.map +1 -0
  27. package/lib/module/components/RequestList/filters.js +57 -0
  28. package/lib/module/components/RequestList/filters.js.map +1 -0
  29. package/lib/module/components/ui/ActionSheet.js +94 -0
  30. package/lib/module/components/ui/ActionSheet.js.map +1 -0
  31. package/lib/module/components/ui/Button.js +104 -0
  32. package/lib/module/components/ui/Button.js.map +1 -0
  33. package/lib/module/components/ui/Pill.js +53 -0
  34. package/lib/module/components/ui/Pill.js.map +1 -0
  35. package/lib/module/components/ui/SearchInput.js +77 -0
  36. package/lib/module/components/ui/SearchInput.js.map +1 -0
  37. package/lib/module/components/ui/Section.js +147 -0
  38. package/lib/module/components/ui/Section.js.map +1 -0
  39. package/lib/module/components/ui/SegmentedControl.js +65 -0
  40. package/lib/module/components/ui/SegmentedControl.js.map +1 -0
  41. package/lib/module/components/ui/index.js +9 -0
  42. package/lib/module/components/ui/index.js.map +1 -0
  43. package/lib/module/core/Logger.js +230 -0
  44. package/lib/module/core/Logger.js.map +1 -0
  45. package/lib/module/core/NetworkRequest.js +155 -0
  46. package/lib/module/core/NetworkRequest.js.map +1 -0
  47. package/lib/module/core/XHRInterceptor.js +104 -0
  48. package/lib/module/core/XHRInterceptor.js.map +1 -0
  49. package/lib/module/core/har.js +72 -0
  50. package/lib/module/core/har.js.map +1 -0
  51. package/lib/module/core/loggerSingleton.js +12 -0
  52. package/lib/module/core/loggerSingleton.js.map +1 -0
  53. package/lib/module/hooks.js +42 -0
  54. package/lib/module/hooks.js.map +1 -0
  55. package/lib/module/index.js +34 -0
  56. package/lib/module/index.js.map +1 -0
  57. package/lib/module/package.json +1 -0
  58. package/lib/module/theme.js +136 -0
  59. package/lib/module/theme.js.map +1 -0
  60. package/lib/module/types.js +2 -0
  61. package/lib/module/types.js.map +1 -0
  62. package/lib/module/utils/body.js +179 -0
  63. package/lib/module/utils/body.js.map +1 -0
  64. package/lib/module/utils/debounce.js +18 -0
  65. package/lib/module/utils/debounce.js.map +1 -0
  66. package/lib/module/utils/format.js +45 -0
  67. package/lib/module/utils/format.js.map +1 -0
  68. package/lib/module/utils/logger.js +6 -0
  69. package/lib/module/utils/logger.js.map +1 -0
  70. package/lib/module/utils/search.js +70 -0
  71. package/lib/module/utils/search.js.map +1 -0
  72. package/lib/module/utils/url.js +50 -0
  73. package/lib/module/utils/url.js.map +1 -0
  74. package/lib/typescript/package.json +1 -0
  75. package/lib/typescript/src/backHandler.d.ts +15 -0
  76. package/lib/typescript/src/backHandler.d.ts.map +1 -0
  77. package/lib/typescript/src/components/BodyViewer.d.ts +16 -0
  78. package/lib/typescript/src/components/BodyViewer.d.ts.map +1 -0
  79. package/lib/typescript/src/components/CodeList.d.ts +21 -0
  80. package/lib/typescript/src/components/CodeList.d.ts.map +1 -0
  81. package/lib/typescript/src/components/JsonTree/JsonTreeView.d.ts +21 -0
  82. package/lib/typescript/src/components/JsonTree/JsonTreeView.d.ts.map +1 -0
  83. package/lib/typescript/src/components/JsonTree/model.d.ts +53 -0
  84. package/lib/typescript/src/components/JsonTree/model.d.ts.map +1 -0
  85. package/lib/typescript/src/components/NetworkLens.d.ts +31 -0
  86. package/lib/typescript/src/components/NetworkLens.d.ts.map +1 -0
  87. package/lib/typescript/src/components/NetworkLensOverlay.d.ts +32 -0
  88. package/lib/typescript/src/components/NetworkLensOverlay.d.ts.map +1 -0
  89. package/lib/typescript/src/components/RawTextView.d.ts +14 -0
  90. package/lib/typescript/src/components/RawTextView.d.ts.map +1 -0
  91. package/lib/typescript/src/components/RequestDetails/RequestDetails.d.ts +9 -0
  92. package/lib/typescript/src/components/RequestDetails/RequestDetails.d.ts.map +1 -0
  93. package/lib/typescript/src/components/RequestList/FilterBar.d.ts +12 -0
  94. package/lib/typescript/src/components/RequestList/FilterBar.d.ts.map +1 -0
  95. package/lib/typescript/src/components/RequestList/RequestList.d.ts +18 -0
  96. package/lib/typescript/src/components/RequestList/RequestList.d.ts.map +1 -0
  97. package/lib/typescript/src/components/RequestList/RequestRow.d.ts +11 -0
  98. package/lib/typescript/src/components/RequestList/RequestRow.d.ts.map +1 -0
  99. package/lib/typescript/src/components/RequestList/filters.d.ts +17 -0
  100. package/lib/typescript/src/components/RequestList/filters.d.ts.map +1 -0
  101. package/lib/typescript/src/components/ui/ActionSheet.d.ts +15 -0
  102. package/lib/typescript/src/components/ui/ActionSheet.d.ts.map +1 -0
  103. package/lib/typescript/src/components/ui/Button.d.ts +21 -0
  104. package/lib/typescript/src/components/ui/Button.d.ts.map +1 -0
  105. package/lib/typescript/src/components/ui/Pill.d.ts +11 -0
  106. package/lib/typescript/src/components/ui/Pill.d.ts.map +1 -0
  107. package/lib/typescript/src/components/ui/SearchInput.d.ts +17 -0
  108. package/lib/typescript/src/components/ui/SearchInput.d.ts.map +1 -0
  109. package/lib/typescript/src/components/ui/Section.d.ts +16 -0
  110. package/lib/typescript/src/components/ui/Section.d.ts.map +1 -0
  111. package/lib/typescript/src/components/ui/SegmentedControl.d.ts +14 -0
  112. package/lib/typescript/src/components/ui/SegmentedControl.d.ts.map +1 -0
  113. package/lib/typescript/src/components/ui/index.d.ts +7 -0
  114. package/lib/typescript/src/components/ui/index.d.ts.map +1 -0
  115. package/lib/typescript/src/core/Logger.d.ts +48 -0
  116. package/lib/typescript/src/core/Logger.d.ts.map +1 -0
  117. package/lib/typescript/src/core/NetworkRequest.d.ts +48 -0
  118. package/lib/typescript/src/core/NetworkRequest.d.ts.map +1 -0
  119. package/lib/typescript/src/core/XHRInterceptor.d.ts +29 -0
  120. package/lib/typescript/src/core/XHRInterceptor.d.ts.map +1 -0
  121. package/lib/typescript/src/core/har.d.ts +65 -0
  122. package/lib/typescript/src/core/har.d.ts.map +1 -0
  123. package/lib/typescript/src/core/loggerSingleton.d.ts +9 -0
  124. package/lib/typescript/src/core/loggerSingleton.d.ts.map +1 -0
  125. package/lib/typescript/src/hooks.d.ts +11 -0
  126. package/lib/typescript/src/hooks.d.ts.map +1 -0
  127. package/lib/typescript/src/index.d.ts +33 -0
  128. package/lib/typescript/src/index.d.ts.map +1 -0
  129. package/lib/typescript/src/theme.d.ts +49 -0
  130. package/lib/typescript/src/theme.d.ts.map +1 -0
  131. package/lib/typescript/src/types.d.ts +80 -0
  132. package/lib/typescript/src/types.d.ts.map +1 -0
  133. package/lib/typescript/src/utils/body.d.ts +19 -0
  134. package/lib/typescript/src/utils/body.d.ts.map +1 -0
  135. package/lib/typescript/src/utils/debounce.d.ts +4 -0
  136. package/lib/typescript/src/utils/debounce.d.ts.map +1 -0
  137. package/lib/typescript/src/utils/format.d.ts +7 -0
  138. package/lib/typescript/src/utils/format.d.ts.map +1 -0
  139. package/lib/typescript/src/utils/logger.d.ts +2 -0
  140. package/lib/typescript/src/utils/logger.d.ts.map +1 -0
  141. package/lib/typescript/src/utils/search.d.ts +17 -0
  142. package/lib/typescript/src/utils/search.d.ts.map +1 -0
  143. package/lib/typescript/src/utils/url.d.ts +15 -0
  144. package/lib/typescript/src/utils/url.d.ts.map +1 -0
  145. package/package.json +186 -4
  146. package/src/backHandler.ts +29 -0
  147. package/src/components/BodyViewer.tsx +250 -0
  148. package/src/components/CodeList.tsx +120 -0
  149. package/src/components/JsonTree/JsonTreeView.tsx +304 -0
  150. package/src/components/JsonTree/model.ts +266 -0
  151. package/src/components/NetworkLens.tsx +176 -0
  152. package/src/components/NetworkLensOverlay.tsx +187 -0
  153. package/src/components/RawTextView.tsx +163 -0
  154. package/src/components/RequestDetails/RequestDetails.tsx +315 -0
  155. package/src/components/RequestList/FilterBar.tsx +168 -0
  156. package/src/components/RequestList/RequestList.tsx +226 -0
  157. package/src/components/RequestList/RequestRow.tsx +150 -0
  158. package/src/components/RequestList/filters.ts +98 -0
  159. package/src/components/ui/ActionSheet.tsx +109 -0
  160. package/src/components/ui/Button.tsx +124 -0
  161. package/src/components/ui/Pill.tsx +53 -0
  162. package/src/components/ui/SearchInput.tsx +97 -0
  163. package/src/components/ui/Section.tsx +133 -0
  164. package/src/components/ui/SegmentedControl.tsx +79 -0
  165. package/src/components/ui/index.ts +6 -0
  166. package/src/core/Logger.ts +293 -0
  167. package/src/core/NetworkRequest.ts +216 -0
  168. package/src/core/XHRInterceptor.ts +179 -0
  169. package/src/core/har.ts +71 -0
  170. package/src/core/loggerSingleton.ts +14 -0
  171. package/src/hooks.ts +51 -0
  172. package/src/index.tsx +56 -0
  173. package/src/theme.ts +174 -0
  174. package/src/types.ts +83 -0
  175. package/src/utils/body.ts +194 -0
  176. package/src/utils/debounce.ts +18 -0
  177. package/src/utils/format.ts +50 -0
  178. package/src/utils/logger.ts +3 -0
  179. package/src/utils/search.ts +72 -0
  180. package/src/utils/url.ts +59 -0
@@ -0,0 +1,250 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, Share, StyleSheet, Text, View } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+ import type { ParsedBody } from '../types';
5
+ import { bodySize, stringifyJson } from '../utils/body';
6
+ import { formatBytes } from '../utils/format';
7
+ import JsonTreeView, { type JsonTreeHandle } from './JsonTree/JsonTreeView';
8
+ import RawTextView from './RawTextView';
9
+ import {
10
+ ActionSheet,
11
+ EmptyState,
12
+ SearchInput,
13
+ SegmentedControl,
14
+ TextButton,
15
+ } from './ui';
16
+
17
+ type ViewMode = 'tree' | 'raw';
18
+
19
+ interface Props {
20
+ body: ParsedBody | undefined;
21
+ loading?: boolean;
22
+ emptyTitle: string;
23
+ emptySubtitle?: string;
24
+ copyToClipboard?: (text: string) => void;
25
+ }
26
+
27
+ const KIND_LABEL: Record<ParsedBody['kind'], string> = {
28
+ json: 'JSON',
29
+ form: 'Form data',
30
+ text: 'Text',
31
+ binary: 'Binary',
32
+ empty: 'Empty',
33
+ };
34
+
35
+ const share = (text: string) =>
36
+ Share.share({ message: text }).catch(() => undefined);
37
+ const valueText = (value: unknown) =>
38
+ typeof value === 'string' ? value : stringifyJson(value);
39
+
40
+ /**
41
+ * Payload viewer used by the Request and Response tabs. Structured bodies can
42
+ * be shown as a collapsible tree or as pretty-printed raw text; both keep lines
43
+ * unwrapped, scroll horizontally and support search with next/previous.
44
+ */
45
+ const BodyViewer = ({
46
+ body,
47
+ loading,
48
+ emptyTitle,
49
+ emptySubtitle,
50
+ copyToClipboard,
51
+ }: Props) => {
52
+ const theme = useTheme();
53
+ const structured = body?.kind === 'json' || body?.kind === 'form';
54
+ const [mode, setMode] = useState<ViewMode>('tree');
55
+ const [query, setQuery] = useState('');
56
+ const [matchCount, setMatchCount] = useState(0);
57
+ const [matchIndex, setMatchIndex] = useState(0);
58
+ const [nodeMenu, setNodeMenu] = useState<{
59
+ path: string;
60
+ value: unknown;
61
+ } | null>(null);
62
+ const treeRef = useRef<JsonTreeHandle>(null);
63
+
64
+ const effectiveMode: ViewMode = structured ? mode : 'raw';
65
+
66
+ useEffect(() => setMatchIndex(0), [query, effectiveMode, body]);
67
+
68
+ const onMatchCountChange = useCallback((count: number) => {
69
+ setMatchCount(count);
70
+ setMatchIndex((index) => (count === 0 ? 0 : Math.min(index, count - 1)));
71
+ }, []);
72
+
73
+ const matches = {
74
+ count: matchCount,
75
+ index: matchIndex,
76
+ onNext: () => matchCount && setMatchIndex((i) => (i + 1) % matchCount),
77
+ onPrev: () =>
78
+ matchCount && setMatchIndex((i) => (i - 1 + matchCount) % matchCount),
79
+ };
80
+
81
+ const meta = useMemo(() => {
82
+ if (!body || body.kind === 'empty') return '';
83
+ const parts = [KIND_LABEL[body.kind]];
84
+ if (body.kind === 'binary' && body.mimeType) parts.push(body.mimeType);
85
+ const size = bodySize(body);
86
+ if (size) parts.push(formatBytes(size));
87
+ if (body.kind !== 'binary')
88
+ parts.push(`${body.text.split('\n').length} lines`);
89
+ return parts.join(' · ');
90
+ }, [body]);
91
+
92
+ if (loading) {
93
+ return (
94
+ <View style={styles.center}>
95
+ <ActivityIndicator color={theme.colors.primary} />
96
+ </View>
97
+ );
98
+ }
99
+ if (!body || body.kind === 'empty') {
100
+ return <EmptyState title={emptyTitle} subtitle={emptySubtitle} />;
101
+ }
102
+
103
+ return (
104
+ <View style={styles.container}>
105
+ <View style={styles.toolbar}>
106
+ {structured ? (
107
+ <SegmentedControl<ViewMode>
108
+ value={effectiveMode}
109
+ onChange={setMode}
110
+ options={[
111
+ { value: 'tree', label: 'Tree' },
112
+ { value: 'raw', label: 'Raw' },
113
+ ]}
114
+ style={styles.modeSwitch}
115
+ />
116
+ ) : (
117
+ <Text
118
+ style={[styles.meta, { color: theme.colors.textMuted }]}
119
+ numberOfLines={1}
120
+ >
121
+ {meta}
122
+ </Text>
123
+ )}
124
+ <View style={styles.spacer} />
125
+ {effectiveMode === 'tree' && (
126
+ <>
127
+ <TextButton
128
+ label="Expand"
129
+ onPress={() => treeRef.current?.expandAll()}
130
+ style={styles.toolButton}
131
+ />
132
+ <TextButton
133
+ label="Collapse"
134
+ onPress={() => treeRef.current?.collapseAll()}
135
+ style={styles.toolButton}
136
+ />
137
+ </>
138
+ )}
139
+ {copyToClipboard && body.kind !== 'binary' && (
140
+ <TextButton
141
+ label="Copy"
142
+ onPress={() => copyToClipboard(body.text)}
143
+ style={styles.toolButton}
144
+ />
145
+ )}
146
+ <TextButton
147
+ label="Share"
148
+ onPress={() => share(body.text)}
149
+ style={styles.toolButton}
150
+ />
151
+ </View>
152
+ {body.kind !== 'binary' && (
153
+ <SearchInput
154
+ value={query}
155
+ onChangeText={setQuery}
156
+ placeholder={
157
+ effectiveMode === 'tree' ? 'Search keys and values' : 'Search text'
158
+ }
159
+ matches={matches}
160
+ style={styles.search}
161
+ />
162
+ )}
163
+ {structured && (
164
+ <Text
165
+ style={[
166
+ styles.meta,
167
+ styles.metaBelow,
168
+ { color: theme.colors.textMuted },
169
+ ]}
170
+ numberOfLines={1}
171
+ >
172
+ {meta}
173
+ </Text>
174
+ )}
175
+ <View style={[styles.viewer, { borderColor: theme.colors.border }]}>
176
+ {body.kind === 'binary' ? (
177
+ <EmptyState title="Binary content" subtitle={body.text} />
178
+ ) : effectiveMode === 'tree' && structured ? (
179
+ <JsonTreeView
180
+ ref={treeRef}
181
+ value={body.value}
182
+ query={query}
183
+ activeMatch={matchIndex}
184
+ onMatchCountChange={onMatchCountChange}
185
+ onLongPressNode={setNodeMenu}
186
+ />
187
+ ) : (
188
+ <RawTextView
189
+ text={body.text}
190
+ query={query}
191
+ activeMatch={matchIndex}
192
+ onMatchCountChange={onMatchCountChange}
193
+ />
194
+ )}
195
+ </View>
196
+ <ActionSheet
197
+ visible={nodeMenu !== null}
198
+ onClose={() => setNodeMenu(null)}
199
+ title={nodeMenu?.path}
200
+ actions={
201
+ nodeMenu
202
+ ? [
203
+ ...(copyToClipboard
204
+ ? [
205
+ {
206
+ label: 'Copy value',
207
+ onPress: () =>
208
+ copyToClipboard(valueText(nodeMenu.value)),
209
+ },
210
+ ]
211
+ : []),
212
+ {
213
+ label: 'Share value',
214
+ onPress: () => share(valueText(nodeMenu.value)),
215
+ },
216
+ ...(copyToClipboard
217
+ ? [
218
+ {
219
+ label: 'Copy path',
220
+ onPress: () => copyToClipboard(nodeMenu.path),
221
+ },
222
+ ]
223
+ : []),
224
+ ]
225
+ : []
226
+ }
227
+ />
228
+ </View>
229
+ );
230
+ };
231
+
232
+ const styles = StyleSheet.create({
233
+ container: { flex: 1 },
234
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
235
+ toolbar: {
236
+ flexDirection: 'row',
237
+ alignItems: 'center',
238
+ paddingHorizontal: 12,
239
+ paddingTop: 10,
240
+ },
241
+ modeSwitch: { width: 130 },
242
+ toolButton: { marginLeft: 6 },
243
+ spacer: { flex: 1 },
244
+ search: { marginHorizontal: 12, marginTop: 8 },
245
+ meta: { fontSize: 12, flexShrink: 1 },
246
+ metaBelow: { paddingHorizontal: 14, paddingTop: 6 },
247
+ viewer: { flex: 1, marginTop: 8, borderTopWidth: StyleSheet.hairlineWidth },
248
+ });
249
+
250
+ export default BodyViewer;
@@ -0,0 +1,120 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ FlatList,
4
+ ScrollView,
5
+ StyleSheet,
6
+ View,
7
+ type ListRenderItem,
8
+ } from 'react-native';
9
+ import { useTheme } from '../theme';
10
+
11
+ interface Props<T> {
12
+ data: T[];
13
+ renderItem: ListRenderItem<T>;
14
+ keyExtractor: (item: T, index: number) => string;
15
+ /** Estimated width of the widest row, in px. */
16
+ contentWidth: number;
17
+ rowHeight: number;
18
+ /** Row index that should be scrolled into view (e.g. the active search match). */
19
+ activeIndex?: number;
20
+ extraData?: unknown;
21
+ }
22
+
23
+ /**
24
+ * A vertical FlatList inside a horizontal ScrollView. Rows are fixed height and
25
+ * never wrap, so long JSON lines stay on one line and can be panned sideways,
26
+ * just like a code editor. We measure the container once and size the inner
27
+ * list explicitly, which keeps both scroll axes stable while rows virtualise.
28
+ */
29
+ export function CodeList<T>({
30
+ data,
31
+ renderItem,
32
+ keyExtractor,
33
+ contentWidth,
34
+ rowHeight,
35
+ activeIndex,
36
+ extraData,
37
+ }: Props<T>) {
38
+ const theme = useTheme();
39
+ const listRef = useRef<FlatList<T>>(null);
40
+ const [layout, setLayout] = useState({ width: 0, height: 0 });
41
+
42
+ const getItemLayout = useCallback(
43
+ (_: ArrayLike<T> | null | undefined, index: number) => ({
44
+ length: rowHeight,
45
+ offset: rowHeight * index,
46
+ index,
47
+ }),
48
+ [rowHeight]
49
+ );
50
+
51
+ useEffect(() => {
52
+ if (
53
+ activeIndex === undefined ||
54
+ activeIndex < 0 ||
55
+ activeIndex >= data.length
56
+ )
57
+ return;
58
+ // Defer so a freshly expanded tree has rendered before we scroll.
59
+ const handle = setTimeout(() => {
60
+ listRef.current?.scrollToIndex({
61
+ index: activeIndex,
62
+ viewPosition: 0.3,
63
+ animated: true,
64
+ });
65
+ }, 16);
66
+ return () => clearTimeout(handle);
67
+ }, [activeIndex, data.length]);
68
+
69
+ const width = Math.max(contentWidth, layout.width);
70
+
71
+ return (
72
+ <View
73
+ style={[styles.container, { backgroundColor: theme.colors.surface }]}
74
+ onLayout={(event) => {
75
+ const { width: w, height: h } = event.nativeEvent.layout;
76
+ setLayout((prev) =>
77
+ prev.width === w && prev.height === h ? prev : { width: w, height: h }
78
+ );
79
+ }}
80
+ >
81
+ {layout.height > 0 && (
82
+ <ScrollView
83
+ horizontal
84
+ bounces={false}
85
+ showsHorizontalScrollIndicator
86
+ nestedScrollEnabled
87
+ keyboardShouldPersistTaps="handled"
88
+ >
89
+ <FlatList
90
+ ref={listRef}
91
+ data={data}
92
+ extraData={extraData}
93
+ renderItem={renderItem}
94
+ keyExtractor={keyExtractor}
95
+ getItemLayout={getItemLayout}
96
+ nestedScrollEnabled
97
+ keyboardShouldPersistTaps="handled"
98
+ initialNumToRender={30}
99
+ maxToRenderPerBatch={40}
100
+ windowSize={9}
101
+ removeClippedSubviews={false}
102
+ onScrollToIndexFailed={({ index }) => {
103
+ listRef.current?.scrollToOffset({
104
+ offset: index * rowHeight,
105
+ animated: false,
106
+ });
107
+ }}
108
+ style={{ width, height: layout.height }}
109
+ contentContainerStyle={styles.content}
110
+ />
111
+ </ScrollView>
112
+ )}
113
+ </View>
114
+ );
115
+ }
116
+
117
+ const styles = StyleSheet.create({
118
+ container: { flex: 1 },
119
+ content: { paddingVertical: 6 },
120
+ });
@@ -0,0 +1,304 @@
1
+ import {
2
+ forwardRef,
3
+ memo,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useMemo,
8
+ useState,
9
+ } from 'react';
10
+ import { Pressable, StyleSheet, Text } from 'react-native';
11
+ import { useTheme, type Theme } from '../../theme';
12
+ import { splitHighlights } from '../../utils/search';
13
+ import { CodeList } from '../CodeList';
14
+ import {
15
+ MAX_VALUE_CHARS,
16
+ ROOT_PATH,
17
+ allPaths,
18
+ collapsedPreview,
19
+ displayValue,
20
+ flattenJson,
21
+ pathsToDepth,
22
+ rowCharCount,
23
+ searchJson,
24
+ subtreePaths,
25
+ type JsonRow,
26
+ } from './model';
27
+
28
+ export interface JsonTreeHandle {
29
+ expandAll: () => void;
30
+ collapseAll: () => void;
31
+ }
32
+
33
+ interface Props {
34
+ value: unknown;
35
+ /** Search query; matching keys/values are highlighted and auto-expanded. */
36
+ query?: string;
37
+ /** Index into the ordered list of matches that should be focused. */
38
+ activeMatch?: number;
39
+ onMatchCountChange?: (count: number) => void;
40
+ onLongPressNode?: (info: { path: string; value: unknown }) => void;
41
+ /** How many levels to open initially. @default 1 */
42
+ initialDepth?: number;
43
+ }
44
+
45
+ const INDENT = 16;
46
+ const INDENT_CHARS = 2;
47
+
48
+ const JsonTreeView = forwardRef<JsonTreeHandle, Props>(
49
+ function JsonTreeViewInner(
50
+ {
51
+ value,
52
+ query = '',
53
+ activeMatch = 0,
54
+ onMatchCountChange,
55
+ onLongPressNode,
56
+ initialDepth = 1,
57
+ },
58
+ ref
59
+ ) {
60
+ const theme = useTheme();
61
+ const [expanded, setExpanded] = useState<Set<string>>(() =>
62
+ pathsToDepth(value, initialDepth)
63
+ );
64
+
65
+ // Reset when a different value is shown.
66
+ useEffect(() => {
67
+ setExpanded(pathsToDepth(value, initialDepth));
68
+ }, [value, initialDepth]);
69
+
70
+ const hits = useMemo(() => searchJson(value, query), [value, query]);
71
+ const matchedPaths = useMemo(
72
+ () => new Set(hits.map((hit) => hit.path)),
73
+ [hits]
74
+ );
75
+
76
+ useEffect(() => {
77
+ onMatchCountChange?.(hits.length);
78
+ }, [hits.length, onMatchCountChange]);
79
+
80
+ // Make every hit reachable by expanding its ancestors.
81
+ useEffect(() => {
82
+ if (hits.length === 0) return;
83
+ setExpanded((prev) => {
84
+ let changed = false;
85
+ const next = new Set(prev);
86
+ for (const hit of hits) {
87
+ for (const ancestor of hit.ancestors) {
88
+ if (!next.has(ancestor)) {
89
+ next.add(ancestor);
90
+ changed = true;
91
+ }
92
+ }
93
+ }
94
+ return changed ? next : prev;
95
+ });
96
+ }, [hits]);
97
+
98
+ useImperativeHandle(
99
+ ref,
100
+ () => ({
101
+ expandAll: () => setExpanded(allPaths(value)),
102
+ collapseAll: () => setExpanded(new Set([ROOT_PATH])),
103
+ }),
104
+ [value]
105
+ );
106
+
107
+ const rows = useMemo(() => flattenJson(value, expanded), [value, expanded]);
108
+
109
+ const activePath =
110
+ hits[Math.min(activeMatch, Math.max(hits.length - 1, 0))]?.path;
111
+ const activeIndex = useMemo(
112
+ () =>
113
+ activePath ? rows.findIndex((row) => row.path === activePath) : -1,
114
+ [rows, activePath]
115
+ );
116
+
117
+ const contentWidth = useMemo(() => {
118
+ let maxChars = 0;
119
+ for (const row of rows)
120
+ maxChars = Math.max(maxChars, rowCharCount(row, INDENT_CHARS));
121
+ return maxChars * theme.sizes.codeFontSize * 0.62 + 40;
122
+ }, [rows, theme.sizes.codeFontSize]);
123
+
124
+ const toggle = useCallback((row: JsonRow) => {
125
+ setExpanded((prev) => {
126
+ const next = new Set(prev);
127
+ if (next.has(row.path)) {
128
+ // Collapse the whole subtree so re-opening starts clean.
129
+ for (const path of subtreePaths(row.value, row.path))
130
+ next.delete(path);
131
+ } else {
132
+ next.add(row.path);
133
+ }
134
+ return next;
135
+ });
136
+ }, []);
137
+
138
+ const rowHeight = theme.sizes.codeLineHeight + 4;
139
+
140
+ const renderItem = useCallback(
141
+ ({ item }: { item: JsonRow }) => (
142
+ <TreeRow
143
+ row={item}
144
+ theme={theme}
145
+ query={query}
146
+ matched={matchedPaths.has(item.path)}
147
+ active={item.path === activePath}
148
+ onToggle={toggle}
149
+ onLongPress={onLongPressNode}
150
+ height={rowHeight}
151
+ />
152
+ ),
153
+ [
154
+ theme,
155
+ query,
156
+ matchedPaths,
157
+ activePath,
158
+ toggle,
159
+ onLongPressNode,
160
+ rowHeight,
161
+ ]
162
+ );
163
+
164
+ return (
165
+ <CodeList
166
+ data={rows}
167
+ renderItem={renderItem}
168
+ keyExtractor={keyExtractor}
169
+ contentWidth={contentWidth}
170
+ rowHeight={rowHeight}
171
+ activeIndex={activeIndex >= 0 ? activeIndex : undefined}
172
+ extraData={`${query}:${activePath ?? ''}`}
173
+ />
174
+ );
175
+ }
176
+ );
177
+
178
+ const keyExtractor = (row: JsonRow) => row.path;
179
+
180
+ const TreeRow = memo(function TreeRowInner({
181
+ row,
182
+ theme,
183
+ query,
184
+ matched,
185
+ active,
186
+ onToggle,
187
+ onLongPress,
188
+ height,
189
+ }: {
190
+ row: JsonRow;
191
+ theme: Theme;
192
+ query: string;
193
+ matched: boolean;
194
+ active: boolean;
195
+ onToggle: (row: JsonRow) => void;
196
+ onLongPress?: (info: { path: string; value: unknown }) => void;
197
+ height: number;
198
+ }) {
199
+ const codeStyle = {
200
+ fontFamily: theme.fonts.mono,
201
+ fontSize: theme.sizes.codeFontSize,
202
+ lineHeight: theme.sizes.codeLineHeight,
203
+ };
204
+ const valueColor = (() => {
205
+ switch (row.type) {
206
+ case 'string':
207
+ return theme.colors.jsonString;
208
+ case 'number':
209
+ return theme.colors.jsonNumber;
210
+ case 'boolean':
211
+ return theme.colors.jsonBoolean;
212
+ case 'null':
213
+ case 'undefined':
214
+ return theme.colors.jsonNull;
215
+ default:
216
+ return theme.colors.textMuted;
217
+ }
218
+ })();
219
+
220
+ let valueText: string;
221
+ if (row.expandable) {
222
+ valueText = row.expanded
223
+ ? row.type === 'array'
224
+ ? `Array(${row.childCount})`
225
+ : `Object (${row.childCount})`
226
+ : collapsedPreview(row.value);
227
+ } else {
228
+ const full = displayValue(row.value);
229
+ valueText =
230
+ full.length > MAX_VALUE_CHARS
231
+ ? `${full.slice(0, MAX_VALUE_CHARS)}… (+${full.length - MAX_VALUE_CHARS})`
232
+ : full;
233
+ }
234
+
235
+ const highlightStyle = {
236
+ backgroundColor: active
237
+ ? theme.colors.highlightActive
238
+ : theme.colors.highlight,
239
+ color: theme.colors.onHighlight,
240
+ };
241
+
242
+ const renderHighlighted = (text: string, color: string) =>
243
+ matched && query
244
+ ? splitHighlights(text, query).map((segment, index) => (
245
+ <Text
246
+ key={index}
247
+ style={segment.highlighted ? highlightStyle : { color }}
248
+ >
249
+ {segment.text}
250
+ </Text>
251
+ ))
252
+ : text;
253
+
254
+ const rowBackground = active
255
+ ? { backgroundColor: `${theme.colors.highlightActive}33` }
256
+ : matched
257
+ ? { backgroundColor: `${theme.colors.highlight}55` }
258
+ : null;
259
+
260
+ return (
261
+ <Pressable
262
+ onPress={row.expandable ? () => onToggle(row) : undefined}
263
+ onLongPress={
264
+ onLongPress
265
+ ? () => onLongPress({ path: row.path, value: row.value })
266
+ : undefined
267
+ }
268
+ delayLongPress={350}
269
+ accessibilityRole={row.expandable ? 'button' : 'text'}
270
+ accessibilityState={
271
+ row.expandable ? { expanded: row.expanded } : undefined
272
+ }
273
+ style={[
274
+ styles.row,
275
+ { height, paddingLeft: 8 + row.depth * INDENT },
276
+ rowBackground,
277
+ ]}
278
+ >
279
+ <Text
280
+ style={[styles.arrow, codeStyle, { color: theme.colors.textMuted }]}
281
+ >
282
+ {row.expandable ? (row.expanded ? '▾' : '▸') : ' '}
283
+ </Text>
284
+ <Text numberOfLines={1} style={[codeStyle, { color: theme.colors.text }]}>
285
+ {row.key !== null && (
286
+ <Text style={{ color: theme.colors.jsonKey }}>
287
+ {renderHighlighted(row.key, theme.colors.jsonKey)}
288
+ <Text style={{ color: theme.colors.textMuted }}>: </Text>
289
+ </Text>
290
+ )}
291
+ <Text style={{ color: valueColor }}>
292
+ {renderHighlighted(valueText, valueColor)}
293
+ </Text>
294
+ </Text>
295
+ </Pressable>
296
+ );
297
+ });
298
+
299
+ const styles = StyleSheet.create({
300
+ row: { flexDirection: 'row', alignItems: 'center', paddingRight: 16 },
301
+ arrow: { width: 16, textAlign: 'center' },
302
+ });
303
+
304
+ export default JsonTreeView;