@rozenite/network-activity-plugin 1.9.0 → 1.11.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 (84) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
  4. package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +337 -61
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/NetworkTimeline.tsx +422 -0
  27. package/src/ui/components/RequestList.tsx +19 -40
  28. package/src/ui/components/SidePanel.tsx +42 -1
  29. package/src/ui/components/Toolbar.tsx +13 -1
  30. package/src/ui/components/ViewToggle.tsx +44 -0
  31. package/src/ui/components/XmlTree.tsx +160 -0
  32. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  33. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  34. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  35. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  36. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  37. package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
  38. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  39. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  40. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  41. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  42. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  43. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  44. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  45. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  46. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  47. package/src/ui/response-renderers/binary.tsx +31 -0
  48. package/src/ui/response-renderers/empty.tsx +14 -0
  49. package/src/ui/response-renderers/html.tsx +36 -0
  50. package/src/ui/response-renderers/image.tsx +37 -0
  51. package/src/ui/response-renderers/index.ts +55 -0
  52. package/src/ui/response-renderers/json.tsx +40 -0
  53. package/src/ui/response-renderers/svg.tsx +27 -0
  54. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  55. package/src/ui/response-renderers/types.ts +38 -0
  56. package/src/ui/response-renderers/unknown.tsx +18 -0
  57. package/src/ui/response-renderers/xml.tsx +46 -0
  58. package/src/ui/state/__tests__/store.test.ts +77 -0
  59. package/src/ui/state/derived.ts +14 -0
  60. package/src/ui/state/filter.ts +49 -0
  61. package/src/ui/state/hooks.ts +2 -2
  62. package/src/ui/state/model.ts +7 -1
  63. package/src/ui/state/store.ts +63 -4
  64. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  65. package/src/ui/tabs/ResponseTab.tsx +80 -97
  66. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  67. package/src/ui/utils/__tests__/download.test.ts +115 -0
  68. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  69. package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
  70. package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
  71. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  72. package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
  73. package/src/ui/utils/download.ts +161 -0
  74. package/src/ui/utils/hex.ts +59 -0
  75. package/src/ui/utils/initiator.ts +136 -0
  76. package/src/ui/utils/requestFilters.ts +183 -0
  77. package/src/ui/utils/sessionExport.ts +185 -0
  78. package/src/ui/utils/symbolication.ts +248 -0
  79. package/src/ui/utils/timelineModel.ts +352 -0
  80. package/src/ui/views/InspectorView.tsx +43 -8
  81. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  82. package/src/utils/getContentTypeMimeType.ts +14 -0
  83. package/vite.config.ts +5 -1
  84. package/vitest.setup.ts +31 -0
@@ -1,8 +1,15 @@
1
- import { HTMLProps } from 'react';
1
+ import { HTMLProps, useMemo } from 'react';
2
+ import { Virtuoso } from 'react-virtuoso';
2
3
  import { cn } from '../utils/cn';
3
4
 
4
5
  export type CodeBlockProps = HTMLProps<HTMLPreElement>;
5
6
 
7
+ // Above this character count, string content renders through Virtuoso
8
+ // instead of a flat <pre>. Tuned so typical responses (<20KB) stay on
9
+ // the simple path, while pathological payloads (large pretty-printed
10
+ // JSON / minified bundles served as text / huge logs) virtualize.
11
+ const VIRTUALIZATION_THRESHOLD = 50_000;
12
+
6
13
  const codeBlockClassNames =
7
14
  'text-sm font-mono text-gray-300 whitespace-pre-wrap bg-gray-800 p-3 rounded-md border border-gray-700 overflow-x-auto wrap-anywhere';
8
15
 
@@ -11,9 +18,46 @@ export const CodeBlock = ({
11
18
  className,
12
19
  ...props
13
20
  }: CodeBlockProps) => {
21
+ // Only string children are eligible for virtualization. Component
22
+ // children (JsonTree / XmlTree / etc.) manage their own rendering;
23
+ // CodeBlock here just provides the monospace-on-dark frame.
24
+ if (
25
+ typeof children === 'string' &&
26
+ children.length > VIRTUALIZATION_THRESHOLD
27
+ ) {
28
+ return <VirtualizedCodeBlock text={children} className={className} />;
29
+ }
30
+
14
31
  return (
15
32
  <pre className={cn(codeBlockClassNames, className)} {...props}>
16
33
  {children}
17
34
  </pre>
18
35
  );
19
36
  };
37
+
38
+ type VirtualizedCodeBlockProps = {
39
+ text: string;
40
+ className?: string;
41
+ };
42
+
43
+ const VirtualizedCodeBlock = ({
44
+ text,
45
+ className,
46
+ }: VirtualizedCodeBlockProps) => {
47
+ const lines = useMemo(() => text.split('\n'), [text]);
48
+
49
+ // Content with no newlines collapses to a single row containing the
50
+ // entire payload. Browser wrapping (whitespace-pre-wrap, wrap-anywhere)
51
+ // still keeps layout sane, but there's no real virtualization benefit
52
+ // for that shape — the size win shows up once the body has many lines.
53
+ return (
54
+ <Virtuoso
55
+ style={{ height: 500 }}
56
+ totalCount={lines.length}
57
+ itemContent={(idx) => (
58
+ <div className="whitespace-pre-wrap wrap-anywhere">{lines[idx]}</div>
59
+ )}
60
+ className={cn(codeBlockClassNames, className)}
61
+ />
62
+ );
63
+ };
@@ -1,29 +1,189 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ autoUpdate,
4
+ flip,
5
+ FloatingPortal,
6
+ offset,
7
+ shift,
8
+ size,
9
+ useClick,
10
+ useDismiss,
11
+ useFloating,
12
+ useInteractions,
13
+ useRole,
14
+ } from '@floating-ui/react';
1
15
  import { Input } from './Input';
2
16
  import { Button } from './Button';
3
- import { X, Filter, ChevronDown } from 'lucide-react';
17
+ import { X, Filter, ChevronDown, Check } from 'lucide-react';
18
+ import type { HttpMethod, NetworkEventSource } from '../../shared/client';
4
19
  import {
5
- DropdownMenu,
6
- DropdownMenuTrigger,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- } from './DropdownMenu';
10
-
11
- export type FilterState = {
12
- text: string;
13
- types: Set<'http' | 'websocket' | 'sse'>;
14
- };
20
+ createDefaultFilter,
21
+ DEFAULT_REQUEST_TYPES,
22
+ } from '../state/filter';
23
+ import type {
24
+ AdvancedFilterState,
25
+ FilterState,
26
+ RequestTypeFilter,
27
+ } from '../state/filter';
15
28
 
16
29
  type FilterBarProps = {
17
30
  filter: FilterState;
18
31
  onFilterChange: (filter: FilterState) => void;
19
32
  };
20
33
 
34
+ const HTTP_METHODS: HttpMethod[] = [
35
+ 'GET',
36
+ 'POST',
37
+ 'PUT',
38
+ 'PATCH',
39
+ 'DELETE',
40
+ 'HEAD',
41
+ ];
42
+ const SOURCES: NetworkEventSource[] = ['builtin', 'nitro'];
43
+
44
+ const getTypeLabel = (type: RequestTypeFilter) => {
45
+ switch (type) {
46
+ case 'http':
47
+ return 'XHR';
48
+ case 'websocket':
49
+ return 'WS';
50
+ case 'sse':
51
+ return 'SSE';
52
+ }
53
+ };
54
+
55
+ const getSourceLabel = (source: NetworkEventSource) => {
56
+ switch (source) {
57
+ case 'builtin':
58
+ return 'Built-in';
59
+ case 'nitro':
60
+ return 'Nitro';
61
+ }
62
+ };
63
+
64
+ const getAdvancedFilterCount = (advanced: AdvancedFilterState) => {
65
+ return [
66
+ advanced.methods.size > 0,
67
+ advanced.sources.size > 0,
68
+ advanced.status.trim() !== '',
69
+ advanced.domain.trim() !== '',
70
+ advanced.contentType.trim() !== '',
71
+ advanced.failedOnly,
72
+ advanced.inFlightOnly,
73
+ advanced.overriddenOnly,
74
+ advanced.minSize.trim() !== '',
75
+ advanced.maxSize.trim() !== '',
76
+ advanced.minDuration.trim() !== '',
77
+ advanced.maxDuration.trim() !== '',
78
+ ].filter(Boolean).length;
79
+ };
80
+
81
+ const getActiveFilterCount = (filter: FilterState) => {
82
+ const typeFilterCount =
83
+ filter.types.size < DEFAULT_REQUEST_TYPES.length ? 1 : 0;
84
+
85
+ return typeFilterCount + getAdvancedFilterCount(filter.advanced);
86
+ };
87
+
88
+ const FilterField = ({
89
+ label,
90
+ value,
91
+ placeholder,
92
+ onChange,
93
+ }: {
94
+ label: string;
95
+ value: string;
96
+ placeholder: string;
97
+ onChange: (value: string) => void;
98
+ }) => (
99
+ <label className="block space-y-1 px-2 py-1">
100
+ <span className="text-xs text-gray-400">{label}</span>
101
+ <Input
102
+ value={value}
103
+ placeholder={placeholder}
104
+ onChange={(event) => onChange(event.target.value)}
105
+ onClick={(event) => event.stopPropagation()}
106
+ onKeyDown={(event) => event.stopPropagation()}
107
+ className="h-7 bg-gray-900 border-gray-700 text-xs text-gray-100 placeholder:text-gray-500"
108
+ />
109
+ </label>
110
+ );
111
+
112
+ const FilterPanelLabel = ({ children }: { children: string }) => (
113
+ <div className="px-2 py-1.5 text-xs font-semibold text-gray-400">
114
+ {children}
115
+ </div>
116
+ );
117
+
118
+ const FilterPanelSeparator = () => (
119
+ <div className="-mx-1 my-1 h-px bg-gray-700" />
120
+ );
121
+
122
+ const FilterCheckbox = ({
123
+ checked,
124
+ onCheckedChange,
125
+ children,
126
+ }: {
127
+ checked: boolean;
128
+ onCheckedChange: (checked: boolean) => void;
129
+ children: string;
130
+ }) => (
131
+ <button
132
+ type="button"
133
+ role="checkbox"
134
+ aria-checked={checked}
135
+ className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-left text-sm outline-none transition-colors hover:bg-gray-700 focus:bg-gray-700 focus:text-gray-100"
136
+ onClick={() => onCheckedChange(!checked)}
137
+ >
138
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
139
+ {checked && <Check className="h-4 w-4" />}
140
+ </span>
141
+ {children}
142
+ </button>
143
+ );
144
+
21
145
  export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
146
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
147
+ const { refs, floatingStyles, context } = useFloating({
148
+ open: isFilterPanelOpen,
149
+ onOpenChange: setIsFilterPanelOpen,
150
+ whileElementsMounted: autoUpdate,
151
+ middleware: [
152
+ offset(5),
153
+ flip({ padding: 8 }),
154
+ shift({ padding: 8 }),
155
+ size({
156
+ padding: 8,
157
+ apply({ availableHeight, elements }) {
158
+ elements.floating.style.maxHeight = `${availableHeight}px`;
159
+ },
160
+ }),
161
+ ],
162
+ });
163
+ const click = useClick(context);
164
+ const dismiss = useDismiss(context);
165
+ const role = useRole(context);
166
+ const { getReferenceProps, getFloatingProps } = useInteractions([
167
+ click,
168
+ dismiss,
169
+ role,
170
+ ]);
171
+
22
172
  const handleTextChange = (text: string) => {
23
173
  onFilterChange({ ...filter, text });
24
174
  };
25
175
 
26
- const toggleType = (type: 'http' | 'websocket' | 'sse') => {
176
+ const updateAdvancedFilter = (patch: Partial<AdvancedFilterState>) => {
177
+ onFilterChange({
178
+ ...filter,
179
+ advanced: {
180
+ ...filter.advanced,
181
+ ...patch,
182
+ },
183
+ });
184
+ };
185
+
186
+ const toggleType = (type: RequestTypeFilter) => {
27
187
  const newTypes = new Set(filter.types);
28
188
  if (newTypes.has(type)) {
29
189
  newTypes.delete(type);
@@ -33,30 +193,35 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
33
193
  onFilterChange({ ...filter, types: newTypes });
34
194
  };
35
195
 
36
- const clearFilters = () => {
37
- onFilterChange({
38
- text: '',
39
- types: new Set(['http', 'websocket', 'sse']),
40
- });
196
+ const toggleMethod = (method: HttpMethod) => {
197
+ const methods = new Set(filter.advanced.methods);
198
+ if (methods.has(method)) {
199
+ methods.delete(method);
200
+ } else {
201
+ methods.add(method);
202
+ }
203
+ updateAdvancedFilter({ methods });
41
204
  };
42
205
 
43
- const hasActiveFilters = filter.text !== '' || filter.types.size < 3;
44
- const isTypeFilterActive = filter.types.size < 3;
45
-
46
- const getTypeLabel = (type: 'http' | 'websocket' | 'sse') => {
47
- switch (type) {
48
- case 'http':
49
- return 'XHR';
50
- case 'websocket':
51
- return 'WS';
52
- case 'sse':
53
- return 'SSE';
206
+ const toggleSource = (source: NetworkEventSource) => {
207
+ const sources = new Set(filter.advanced.sources);
208
+ if (sources.has(source)) {
209
+ sources.delete(source);
210
+ } else {
211
+ sources.add(source);
54
212
  }
213
+ updateAdvancedFilter({ sources });
55
214
  };
56
215
 
216
+ const clearFilters = () => {
217
+ onFilterChange(createDefaultFilter());
218
+ };
219
+
220
+ const activeFilterCount = getActiveFilterCount(filter);
221
+ const hasActiveFilters = filter.text.trim() !== '' || activeFilterCount > 0;
222
+
57
223
  return (
58
224
  <div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
59
- {/* Text Filter */}
60
225
  <div className="flex-1">
61
226
  <Input
62
227
  placeholder="Filter requests..."
@@ -66,42 +231,153 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
66
231
  />
67
232
  </div>
68
233
 
69
- {/* Request Type Filters Dropdown */}
70
- <DropdownMenu>
71
- <DropdownMenuTrigger asChild>
72
- <Button
73
- variant="ghost"
74
- size="sm"
75
- className={`h-8 px-3 text-xs transition-all ${
76
- isTypeFilterActive
77
- ? 'bg-blue-600/20 border border-blue-500/50 text-blue-300 hover:bg-blue-600/30'
78
- : 'text-gray-300 hover:text-gray-100 hover:bg-gray-700'
79
- }`}
234
+ <Button
235
+ ref={refs.setReference}
236
+ variant="ghost"
237
+ size="sm"
238
+ className={`h-8 px-3 text-xs transition-all ${
239
+ activeFilterCount > 0
240
+ ? 'bg-blue-600/20 border border-blue-500/50 text-blue-300 hover:bg-blue-600/30'
241
+ : 'text-gray-300 hover:text-gray-100 hover:bg-gray-700'
242
+ }`}
243
+ {...getReferenceProps()}
244
+ >
245
+ <Filter className="h-3 w-3 mr-1" />
246
+ Filters
247
+ {activeFilterCount > 0 && (
248
+ <span className="rounded bg-blue-500/30 px-1 text-[10px] text-blue-100">
249
+ {activeFilterCount}
250
+ </span>
251
+ )}
252
+ <ChevronDown className="h-3 w-3 ml-1" />
253
+ </Button>
254
+
255
+ {isFilterPanelOpen && (
256
+ <FloatingPortal>
257
+ <div
258
+ ref={refs.setFloating}
259
+ className="z-50 w-80 space-y-1 overflow-y-auto overscroll-contain rounded-md border border-gray-600 bg-gray-800 p-1 text-gray-100 shadow-lg"
260
+ style={floatingStyles}
261
+ {...getFloatingProps()}
80
262
  >
81
- <Filter className="h-3 w-3 mr-1" />
82
- Types
83
- <ChevronDown className="h-3 w-3 ml-1" />
84
- </Button>
85
- </DropdownMenuTrigger>
86
-
87
- <DropdownMenuContent sideOffset={5} className="space-y-1">
88
- {(['http', 'sse', 'websocket'] as const).map((type) => (
89
- <DropdownMenuItem
90
- key={type}
91
- onClick={() => toggleType(type)}
92
- className={
93
- filter.types.has(type)
94
- ? 'bg-blue-600 text-white'
95
- : 'text-gray-300 hover:bg-gray-700 hover:text-gray-100'
263
+ <FilterPanelLabel>Request Type</FilterPanelLabel>
264
+ {DEFAULT_REQUEST_TYPES.map((type) => (
265
+ <FilterCheckbox
266
+ key={type}
267
+ checked={filter.types.has(type)}
268
+ onCheckedChange={() => toggleType(type)}
269
+ >
270
+ {getTypeLabel(type)}
271
+ </FilterCheckbox>
272
+ ))}
273
+
274
+ <FilterPanelSeparator />
275
+ <FilterPanelLabel>Method</FilterPanelLabel>
276
+ <div className="grid grid-cols-2">
277
+ {HTTP_METHODS.map((method) => (
278
+ <FilterCheckbox
279
+ key={method}
280
+ checked={filter.advanced.methods.has(method)}
281
+ onCheckedChange={() => toggleMethod(method)}
282
+ >
283
+ {method}
284
+ </FilterCheckbox>
285
+ ))}
286
+ </div>
287
+
288
+ <FilterPanelSeparator />
289
+ <FilterPanelLabel>Request Source</FilterPanelLabel>
290
+ {SOURCES.map((source) => (
291
+ <FilterCheckbox
292
+ key={source}
293
+ checked={filter.advanced.sources.has(source)}
294
+ onCheckedChange={() => toggleSource(source)}
295
+ >
296
+ {getSourceLabel(source)}
297
+ </FilterCheckbox>
298
+ ))}
299
+
300
+ <FilterPanelSeparator />
301
+ <FilterCheckbox
302
+ checked={filter.advanced.failedOnly}
303
+ onCheckedChange={(checked) =>
304
+ updateAdvancedFilter({ failedOnly: checked })
305
+ }
306
+ >
307
+ Failed only
308
+ </FilterCheckbox>
309
+ <FilterCheckbox
310
+ checked={filter.advanced.inFlightOnly}
311
+ onCheckedChange={(checked) =>
312
+ updateAdvancedFilter({ inFlightOnly: checked })
313
+ }
314
+ >
315
+ In-flight only
316
+ </FilterCheckbox>
317
+ <FilterCheckbox
318
+ checked={filter.advanced.overriddenOnly}
319
+ onCheckedChange={(checked) =>
320
+ updateAdvancedFilter({ overriddenOnly: checked })
96
321
  }
97
322
  >
98
- {getTypeLabel(type)}
99
- </DropdownMenuItem>
100
- ))}
101
- </DropdownMenuContent>
102
- </DropdownMenu>
323
+ Overridden only
324
+ </FilterCheckbox>
325
+
326
+ <FilterPanelSeparator />
327
+ <div className="grid grid-cols-2 gap-x-2">
328
+ <FilterField
329
+ label="Status"
330
+ value={filter.advanced.status}
331
+ placeholder="200, 2xx, >=400"
332
+ onChange={(status) => updateAdvancedFilter({ status })}
333
+ />
334
+ <FilterField
335
+ label="Domain"
336
+ value={filter.advanced.domain}
337
+ placeholder="api.example.com"
338
+ onChange={(domain) => updateAdvancedFilter({ domain })}
339
+ />
340
+ <FilterField
341
+ label="MIME Type"
342
+ value={filter.advanced.contentType}
343
+ placeholder="json"
344
+ onChange={(contentType) =>
345
+ updateAdvancedFilter({ contentType })
346
+ }
347
+ />
348
+ <FilterField
349
+ label="Min Size"
350
+ value={filter.advanced.minSize}
351
+ placeholder="1024"
352
+ onChange={(minSize) => updateAdvancedFilter({ minSize })}
353
+ />
354
+ <FilterField
355
+ label="Max Size"
356
+ value={filter.advanced.maxSize}
357
+ placeholder="50000"
358
+ onChange={(maxSize) => updateAdvancedFilter({ maxSize })}
359
+ />
360
+ <FilterField
361
+ label="Min Duration"
362
+ value={filter.advanced.minDuration}
363
+ placeholder="500"
364
+ onChange={(minDuration) =>
365
+ updateAdvancedFilter({ minDuration })
366
+ }
367
+ />
368
+ <FilterField
369
+ label="Max Duration"
370
+ value={filter.advanced.maxDuration}
371
+ placeholder="2000"
372
+ onChange={(maxDuration) =>
373
+ updateAdvancedFilter({ maxDuration })
374
+ }
375
+ />
376
+ </div>
377
+ </div>
378
+ </FloatingPortal>
379
+ )}
103
380
 
104
- {/* Clear Filters */}
105
381
  {hasActiveFilters && (
106
382
  <Button
107
383
  variant="ghost"
@@ -0,0 +1,54 @@
1
+ import { Virtuoso } from 'react-virtuoso';
2
+ import {
3
+ BYTES_PER_HEX_ROW,
4
+ formatHexRow,
5
+ rowCountForByteLength,
6
+ } from '../utils/hex';
7
+
8
+ export type HexViewProps = {
9
+ bytes: Uint8Array;
10
+ };
11
+
12
+ type HexRowProps = {
13
+ offset: string;
14
+ hex: string;
15
+ ascii: string;
16
+ };
17
+
18
+ // Rendered each scroll tick by virtuoso. Kept inline rather than
19
+ // memoized because the per-row work is dominated by `formatHexRow`
20
+ // which itself is cheap; memoizing wouldn't change cost meaningfully.
21
+ const HexRow = ({ offset, hex, ascii }: HexRowProps) => (
22
+ <div className="flex gap-4 font-mono text-xs leading-snug whitespace-pre">
23
+ <span className="text-gray-500">{offset}</span>
24
+ <span className="text-gray-200">{hex}</span>
25
+ <span className="text-gray-400">{`|${ascii}|`}</span>
26
+ </div>
27
+ );
28
+
29
+ // Virtualized hex view. Each row is the classic offset / hex / ASCII
30
+ // triple; native text selection covers what's currently rendered
31
+ // (off-screen rows are unmounted, by design). When users need every
32
+ // byte they reach for the Download button on the metadata card.
33
+ export const HexView = ({ bytes }: HexViewProps) => {
34
+ const totalCount = rowCountForByteLength(bytes.byteLength);
35
+
36
+ if (totalCount === 0) {
37
+ return (
38
+ <div className="text-xs text-gray-500 italic">No bytes to display.</div>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div className="bg-gray-900 border border-gray-700 rounded">
44
+ <Virtuoso
45
+ style={{ height: 320 }}
46
+ totalCount={totalCount}
47
+ itemContent={(index) => {
48
+ const row = formatHexRow(bytes, index * BYTES_PER_HEX_ROW);
49
+ return <HexRow offset={row.offset} hex={row.hex} ascii={row.ascii} />;
50
+ }}
51
+ />
52
+ </div>
53
+ );
54
+ };
@@ -0,0 +1,95 @@
1
+ import { Download } from 'lucide-react';
2
+ import { KeyValueGrid } from './KeyValueGrid';
3
+ import {
4
+ base64ToBlob,
5
+ deriveFilename,
6
+ downloadBlob,
7
+ readHeader,
8
+ } from '../utils/download';
9
+ import type { RenderCtx } from '../response-renderers/types';
10
+
11
+ export type MetadataCardBody =
12
+ | { kind: 'binary'; base64: string }
13
+ | { kind: 'binary-too-large'; size: number };
14
+
15
+ export type MetadataCardProps = {
16
+ body: MetadataCardBody;
17
+ ctx: RenderCtx;
18
+ };
19
+
20
+ const formatBytes = (bytes: number): string => {
21
+ if (bytes >= 1024 * 1024) {
22
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
23
+ }
24
+ if (bytes >= 1024) {
25
+ return `${(bytes / 1024).toFixed(1)} KB`;
26
+ }
27
+ return `${bytes} bytes`;
28
+ };
29
+
30
+ // base64 inflates ~33%; subtract trailing `=` padding for the true
31
+ // decoded byte count.
32
+ const decodedByteCount = (base64: string): number => {
33
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
34
+ return Math.floor((base64.length * 3) / 4) - padding;
35
+ };
36
+
37
+ export const MetadataCard = ({ body, ctx }: MetadataCardProps) => {
38
+ const decodedSize =
39
+ body.kind === 'binary' ? decodedByteCount(body.base64) : body.size;
40
+
41
+ const contentLengthHeader = readHeader(ctx.headers, 'Content-Length');
42
+ const filename = deriveFilename({
43
+ headers: ctx.headers,
44
+ url: ctx.url,
45
+ contentType: ctx.contentType,
46
+ });
47
+
48
+ const isDownloadAvailable = body.kind === 'binary';
49
+ const downloadTitle = isDownloadAvailable
50
+ ? `Download ${filename}`
51
+ : `Response too large to download (> 5 MB cap, size: ${formatBytes(decodedSize)})`;
52
+
53
+ const handleDownload = () => {
54
+ if (body.kind !== 'binary') return;
55
+ const blob = base64ToBlob(body.base64, ctx.contentType);
56
+ downloadBlob(blob, filename);
57
+ };
58
+
59
+ return (
60
+ <div className="flex items-start justify-between gap-3 bg-gray-800 border border-gray-700 rounded p-3">
61
+ <div className="flex-1 min-w-0">
62
+ <KeyValueGrid
63
+ items={[
64
+ {
65
+ key: 'Size',
66
+ value: formatBytes(decodedSize),
67
+ },
68
+ ...(contentLengthHeader
69
+ ? [
70
+ {
71
+ key: 'Content-Length',
72
+ value: contentLengthHeader,
73
+ },
74
+ ]
75
+ : []),
76
+ {
77
+ key: 'Filename',
78
+ value: filename,
79
+ },
80
+ ]}
81
+ />
82
+ </div>
83
+ <button
84
+ type="button"
85
+ onClick={handleDownload}
86
+ disabled={!isDownloadAvailable}
87
+ title={downloadTitle}
88
+ className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-violet-500 text-violet-200 hover:bg-violet-900/40 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent transition-colors"
89
+ >
90
+ <Download className="h-3 w-3" />
91
+ Download
92
+ </button>
93
+ </div>
94
+ );
95
+ };