@rozenite/network-activity-plugin 1.8.1 → 1.10.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 (72) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
  4. package/dist/devtools/assets/{App-B3xlUjs6.js → App-DsimzJvx.js} +6833 -966
  5. package/dist/react-native/chunks/boot-recording.cjs +156 -28
  6. package/dist/react-native/chunks/boot-recording.js +156 -28
  7. package/dist/react-native/chunks/get-nitro-module.cjs +12 -0
  8. package/dist/react-native/chunks/get-nitro-module.js +13 -0
  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 +39 -3
  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 +209 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +13 -0
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +10 -11
  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 +366 -58
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/RequestList.tsx +192 -34
  27. package/src/ui/components/SidePanel.tsx +42 -1
  28. package/src/ui/components/ViewToggle.tsx +44 -0
  29. package/src/ui/components/XmlTree.tsx +160 -0
  30. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  31. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  32. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  33. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  34. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  35. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  36. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  37. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  38. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  39. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  40. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  41. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  42. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  43. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  44. package/src/ui/response-renderers/binary.tsx +31 -0
  45. package/src/ui/response-renderers/empty.tsx +14 -0
  46. package/src/ui/response-renderers/html.tsx +36 -0
  47. package/src/ui/response-renderers/image.tsx +37 -0
  48. package/src/ui/response-renderers/index.ts +55 -0
  49. package/src/ui/response-renderers/json.tsx +40 -0
  50. package/src/ui/response-renderers/svg.tsx +27 -0
  51. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  52. package/src/ui/response-renderers/types.ts +38 -0
  53. package/src/ui/response-renderers/unknown.tsx +18 -0
  54. package/src/ui/response-renderers/xml.tsx +46 -0
  55. package/src/ui/state/derived.ts +12 -0
  56. package/src/ui/state/model.ts +6 -1
  57. package/src/ui/state/store.ts +39 -2
  58. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  59. package/src/ui/tabs/ResponseTab.tsx +80 -96
  60. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  61. package/src/ui/utils/__tests__/download.test.ts +115 -0
  62. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  63. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  64. package/src/ui/utils/download.ts +154 -0
  65. package/src/ui/utils/hex.ts +59 -0
  66. package/src/ui/utils/initiator.ts +136 -0
  67. package/src/ui/utils/symbolication.ts +248 -0
  68. package/src/ui/views/InspectorView.tsx +8 -5
  69. package/src/utils/__tests__/getContentTypeMimeType.test.ts +65 -0
  70. package/src/utils/getContentTypeMimeType.ts +28 -0
  71. package/vite.config.ts +9 -1
  72. package/vitest.setup.ts +31 -0
@@ -1,16 +1,42 @@
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';
4
- import {
5
- DropdownMenu,
6
- DropdownMenuTrigger,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- } from './DropdownMenu';
17
+ import { X, Filter, ChevronDown, Check } from 'lucide-react';
18
+ import type { HttpMethod, NetworkEventSource } from '../../shared/client';
19
+
20
+ export type RequestTypeFilter = 'http' | 'websocket' | 'sse';
21
+ export type AdvancedFilterState = {
22
+ methods: Set<HttpMethod>;
23
+ sources: Set<NetworkEventSource>;
24
+ status: string;
25
+ domain: string;
26
+ contentType: string;
27
+ failedOnly: boolean;
28
+ inFlightOnly: boolean;
29
+ overriddenOnly: boolean;
30
+ minSize: string;
31
+ maxSize: string;
32
+ minDuration: string;
33
+ maxDuration: string;
34
+ };
10
35
 
11
36
  export type FilterState = {
12
37
  text: string;
13
- types: Set<'http' | 'websocket' | 'sse'>;
38
+ types: Set<RequestTypeFilter>;
39
+ advanced: AdvancedFilterState;
14
40
  };
15
41
 
16
42
  type FilterBarProps = {
@@ -18,12 +44,178 @@ type FilterBarProps = {
18
44
  onFilterChange: (filter: FilterState) => void;
19
45
  };
20
46
 
47
+ const REQUEST_TYPES: RequestTypeFilter[] = ['http', 'sse', 'websocket'];
48
+ const HTTP_METHODS: HttpMethod[] = [
49
+ 'GET',
50
+ 'POST',
51
+ 'PUT',
52
+ 'PATCH',
53
+ 'DELETE',
54
+ 'HEAD',
55
+ ];
56
+ const SOURCES: NetworkEventSource[] = ['builtin', 'nitro'];
57
+
58
+ export const createDefaultFilter = (): FilterState => ({
59
+ text: '',
60
+ types: new Set(),
61
+ advanced: {
62
+ methods: new Set(),
63
+ sources: new Set(),
64
+ status: '',
65
+ domain: '',
66
+ contentType: '',
67
+ failedOnly: false,
68
+ inFlightOnly: false,
69
+ overriddenOnly: false,
70
+ minSize: '',
71
+ maxSize: '',
72
+ minDuration: '',
73
+ maxDuration: '',
74
+ },
75
+ });
76
+
77
+ const getTypeLabel = (type: RequestTypeFilter) => {
78
+ switch (type) {
79
+ case 'http':
80
+ return 'XHR';
81
+ case 'websocket':
82
+ return 'WS';
83
+ case 'sse':
84
+ return 'SSE';
85
+ }
86
+ };
87
+
88
+ const getSourceLabel = (source: NetworkEventSource) => {
89
+ switch (source) {
90
+ case 'builtin':
91
+ return 'Built-in';
92
+ case 'nitro':
93
+ return 'Nitro';
94
+ }
95
+ };
96
+
97
+ const getAdvancedFilterCount = (advanced: AdvancedFilterState) => {
98
+ return [
99
+ advanced.methods.size > 0,
100
+ advanced.sources.size > 0,
101
+ advanced.status.trim() !== '',
102
+ advanced.domain.trim() !== '',
103
+ advanced.contentType.trim() !== '',
104
+ advanced.failedOnly,
105
+ advanced.inFlightOnly,
106
+ advanced.overriddenOnly,
107
+ advanced.minSize.trim() !== '',
108
+ advanced.maxSize.trim() !== '',
109
+ advanced.minDuration.trim() !== '',
110
+ advanced.maxDuration.trim() !== '',
111
+ ].filter(Boolean).length;
112
+ };
113
+
114
+ const getActiveFilterCount = (filter: FilterState) => {
115
+ const typeFilterCount = filter.types.size > 0 ? 1 : 0;
116
+
117
+ return typeFilterCount + getAdvancedFilterCount(filter.advanced);
118
+ };
119
+
120
+ const FilterField = ({
121
+ label,
122
+ value,
123
+ placeholder,
124
+ onChange,
125
+ }: {
126
+ label: string;
127
+ value: string;
128
+ placeholder: string;
129
+ onChange: (value: string) => void;
130
+ }) => (
131
+ <label className="block space-y-1 px-2 py-1">
132
+ <span className="text-xs text-gray-400">{label}</span>
133
+ <Input
134
+ value={value}
135
+ placeholder={placeholder}
136
+ onChange={(event) => onChange(event.target.value)}
137
+ onClick={(event) => event.stopPropagation()}
138
+ onKeyDown={(event) => event.stopPropagation()}
139
+ className="h-7 bg-gray-900 border-gray-700 text-xs text-gray-100 placeholder:text-gray-500"
140
+ />
141
+ </label>
142
+ );
143
+
144
+ const FilterPanelLabel = ({ children }: { children: string }) => (
145
+ <div className="px-2 py-1.5 text-xs font-semibold text-gray-400">
146
+ {children}
147
+ </div>
148
+ );
149
+
150
+ const FilterPanelSeparator = () => (
151
+ <div className="-mx-1 my-1 h-px bg-gray-700" />
152
+ );
153
+
154
+ const FilterCheckbox = ({
155
+ checked,
156
+ onCheckedChange,
157
+ children,
158
+ }: {
159
+ checked: boolean;
160
+ onCheckedChange: (checked: boolean) => void;
161
+ children: string;
162
+ }) => (
163
+ <button
164
+ type="button"
165
+ role="checkbox"
166
+ aria-checked={checked}
167
+ 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"
168
+ onClick={() => onCheckedChange(!checked)}
169
+ >
170
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
171
+ {checked && <Check className="h-4 w-4" />}
172
+ </span>
173
+ {children}
174
+ </button>
175
+ );
176
+
21
177
  export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
178
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
179
+ const { refs, floatingStyles, context } = useFloating({
180
+ open: isFilterPanelOpen,
181
+ onOpenChange: setIsFilterPanelOpen,
182
+ whileElementsMounted: autoUpdate,
183
+ middleware: [
184
+ offset(5),
185
+ flip({ padding: 8 }),
186
+ shift({ padding: 8 }),
187
+ size({
188
+ padding: 8,
189
+ apply({ availableHeight, elements }) {
190
+ elements.floating.style.maxHeight = `${availableHeight}px`;
191
+ },
192
+ }),
193
+ ],
194
+ });
195
+ const click = useClick(context);
196
+ const dismiss = useDismiss(context);
197
+ const role = useRole(context);
198
+ const { getReferenceProps, getFloatingProps } = useInteractions([
199
+ click,
200
+ dismiss,
201
+ role,
202
+ ]);
203
+
22
204
  const handleTextChange = (text: string) => {
23
205
  onFilterChange({ ...filter, text });
24
206
  };
25
207
 
26
- const toggleType = (type: 'http' | 'websocket' | 'sse') => {
208
+ const updateAdvancedFilter = (patch: Partial<AdvancedFilterState>) => {
209
+ onFilterChange({
210
+ ...filter,
211
+ advanced: {
212
+ ...filter.advanced,
213
+ ...patch,
214
+ },
215
+ });
216
+ };
217
+
218
+ const toggleType = (type: RequestTypeFilter) => {
27
219
  const newTypes = new Set(filter.types);
28
220
  if (newTypes.has(type)) {
29
221
  newTypes.delete(type);
@@ -33,30 +225,35 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
33
225
  onFilterChange({ ...filter, types: newTypes });
34
226
  };
35
227
 
36
- const clearFilters = () => {
37
- onFilterChange({
38
- text: '',
39
- types: new Set(['http', 'websocket', 'sse']),
40
- });
228
+ const toggleMethod = (method: HttpMethod) => {
229
+ const methods = new Set(filter.advanced.methods);
230
+ if (methods.has(method)) {
231
+ methods.delete(method);
232
+ } else {
233
+ methods.add(method);
234
+ }
235
+ updateAdvancedFilter({ methods });
41
236
  };
42
237
 
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';
238
+ const toggleSource = (source: NetworkEventSource) => {
239
+ const sources = new Set(filter.advanced.sources);
240
+ if (sources.has(source)) {
241
+ sources.delete(source);
242
+ } else {
243
+ sources.add(source);
54
244
  }
245
+ updateAdvancedFilter({ sources });
246
+ };
247
+
248
+ const clearFilters = () => {
249
+ onFilterChange(createDefaultFilter());
55
250
  };
56
251
 
252
+ const activeFilterCount = getActiveFilterCount(filter);
253
+ const hasActiveFilters = filter.text !== '' || activeFilterCount > 0;
254
+
57
255
  return (
58
256
  <div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
59
- {/* Text Filter */}
60
257
  <div className="flex-1">
61
258
  <Input
62
259
  placeholder="Filter requests..."
@@ -66,42 +263,153 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
66
263
  />
67
264
  </div>
68
265
 
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
- }`}
266
+ <Button
267
+ ref={refs.setReference}
268
+ variant="ghost"
269
+ size="sm"
270
+ className={`h-8 px-3 text-xs transition-all ${
271
+ activeFilterCount > 0
272
+ ? 'bg-blue-600/20 border border-blue-500/50 text-blue-300 hover:bg-blue-600/30'
273
+ : 'text-gray-300 hover:text-gray-100 hover:bg-gray-700'
274
+ }`}
275
+ {...getReferenceProps()}
276
+ >
277
+ <Filter className="h-3 w-3 mr-1" />
278
+ Filters
279
+ {activeFilterCount > 0 && (
280
+ <span className="rounded bg-blue-500/30 px-1 text-[10px] text-blue-100">
281
+ {activeFilterCount}
282
+ </span>
283
+ )}
284
+ <ChevronDown className="h-3 w-3 ml-1" />
285
+ </Button>
286
+
287
+ {isFilterPanelOpen && (
288
+ <FloatingPortal>
289
+ <div
290
+ ref={refs.setFloating}
291
+ 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"
292
+ style={floatingStyles}
293
+ {...getFloatingProps()}
80
294
  >
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'
295
+ <FilterPanelLabel>Request Type</FilterPanelLabel>
296
+ {REQUEST_TYPES.map((type) => (
297
+ <FilterCheckbox
298
+ key={type}
299
+ checked={filter.types.has(type)}
300
+ onCheckedChange={() => toggleType(type)}
301
+ >
302
+ {getTypeLabel(type)}
303
+ </FilterCheckbox>
304
+ ))}
305
+
306
+ <FilterPanelSeparator />
307
+ <FilterPanelLabel>Method</FilterPanelLabel>
308
+ <div className="grid grid-cols-2">
309
+ {HTTP_METHODS.map((method) => (
310
+ <FilterCheckbox
311
+ key={method}
312
+ checked={filter.advanced.methods.has(method)}
313
+ onCheckedChange={() => toggleMethod(method)}
314
+ >
315
+ {method}
316
+ </FilterCheckbox>
317
+ ))}
318
+ </div>
319
+
320
+ <FilterPanelSeparator />
321
+ <FilterPanelLabel>Request Source</FilterPanelLabel>
322
+ {SOURCES.map((source) => (
323
+ <FilterCheckbox
324
+ key={source}
325
+ checked={filter.advanced.sources.has(source)}
326
+ onCheckedChange={() => toggleSource(source)}
327
+ >
328
+ {getSourceLabel(source)}
329
+ </FilterCheckbox>
330
+ ))}
331
+
332
+ <FilterPanelSeparator />
333
+ <FilterCheckbox
334
+ checked={filter.advanced.failedOnly}
335
+ onCheckedChange={(checked) =>
336
+ updateAdvancedFilter({ failedOnly: checked })
96
337
  }
97
338
  >
98
- {getTypeLabel(type)}
99
- </DropdownMenuItem>
100
- ))}
101
- </DropdownMenuContent>
102
- </DropdownMenu>
339
+ Failed only
340
+ </FilterCheckbox>
341
+ <FilterCheckbox
342
+ checked={filter.advanced.inFlightOnly}
343
+ onCheckedChange={(checked) =>
344
+ updateAdvancedFilter({ inFlightOnly: checked })
345
+ }
346
+ >
347
+ In-flight only
348
+ </FilterCheckbox>
349
+ <FilterCheckbox
350
+ checked={filter.advanced.overriddenOnly}
351
+ onCheckedChange={(checked) =>
352
+ updateAdvancedFilter({ overriddenOnly: checked })
353
+ }
354
+ >
355
+ Overridden only
356
+ </FilterCheckbox>
357
+
358
+ <FilterPanelSeparator />
359
+ <div className="grid grid-cols-2 gap-x-2">
360
+ <FilterField
361
+ label="Status"
362
+ value={filter.advanced.status}
363
+ placeholder="200, 2xx, >=400"
364
+ onChange={(status) => updateAdvancedFilter({ status })}
365
+ />
366
+ <FilterField
367
+ label="Domain"
368
+ value={filter.advanced.domain}
369
+ placeholder="api.example.com"
370
+ onChange={(domain) => updateAdvancedFilter({ domain })}
371
+ />
372
+ <FilterField
373
+ label="MIME Type"
374
+ value={filter.advanced.contentType}
375
+ placeholder="json"
376
+ onChange={(contentType) =>
377
+ updateAdvancedFilter({ contentType })
378
+ }
379
+ />
380
+ <FilterField
381
+ label="Min Size"
382
+ value={filter.advanced.minSize}
383
+ placeholder="1024"
384
+ onChange={(minSize) => updateAdvancedFilter({ minSize })}
385
+ />
386
+ <FilterField
387
+ label="Max Size"
388
+ value={filter.advanced.maxSize}
389
+ placeholder="50000"
390
+ onChange={(maxSize) => updateAdvancedFilter({ maxSize })}
391
+ />
392
+ <FilterField
393
+ label="Min Duration"
394
+ value={filter.advanced.minDuration}
395
+ placeholder="500"
396
+ onChange={(minDuration) =>
397
+ updateAdvancedFilter({ minDuration })
398
+ }
399
+ />
400
+ <FilterField
401
+ label="Max Duration"
402
+ value={filter.advanced.maxDuration}
403
+ placeholder="2000"
404
+ onChange={(maxDuration) =>
405
+ updateAdvancedFilter({ maxDuration })
406
+ }
407
+ />
408
+ </div>
409
+ </div>
410
+ </FloatingPortal>
411
+ )}
103
412
 
104
- {/* Clear Filters */}
105
413
  {hasActiveFilters && (
106
414
  <Button
107
415
  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
+ };