@rozenite/storage-plugin 1.13.0 → 2.1.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.
- package/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
- package/dist/devtools/assets/panel-DBweZnW5.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -66
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +17 -13
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
- package/dist/react-native/index.d.ts +166 -18
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +35 -37
- package/react-native.ts +12 -22
- package/rozenite.config.ts +74 -0
- package/sdk.ts +1 -5
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
- package/src/react-native/__tests__/import.test.ts +174 -11
- package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +140 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
- package/src/react-native/adapters/async-storage.ts +8 -9
- package/src/react-native/adapters/index.ts +2 -8
- package/src/react-native/adapters/mmkv.ts +12 -24
- package/src/react-native/adapters/secure-storage.ts +4 -0
- package/src/react-native/entry-preview-pagination.ts +261 -0
- package/src/react-native/export-snapshot.ts +88 -0
- package/src/react-native/full-entry-request.ts +91 -0
- package/src/react-native/import.ts +179 -16
- package/src/react-native/storage-discovery.ts +92 -0
- package/src/react-native/storage-view.ts +47 -109
- package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
- package/src/react-native/useStorageAgentTools.ts +196 -91
- package/src/shared/__tests__/entry-preview.test.ts +99 -0
- package/src/shared/__tests__/snapshot.test.ts +31 -32
- package/src/shared/agent-tools.ts +33 -38
- package/src/shared/entry-preview.ts +54 -0
- package/src/shared/messaging.ts +140 -21
- package/src/shared/snapshot.ts +12 -18
- package/src/shared/types.ts +21 -4
- package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
- package/src/ui/__tests__/binary.test.ts +31 -33
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
- package/src/ui/__tests__/panel.test.tsx +382 -0
- package/src/ui/__tests__/storage-groups.test.ts +88 -0
- package/src/ui/__tests__/type-conversion.test.ts +8 -24
- package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
- package/src/ui/add-entry-dialog.tsx +66 -117
- package/src/ui/binary-value-editor-state.ts +16 -25
- package/src/ui/binary-value-editor.tsx +59 -47
- package/src/ui/binary.ts +54 -15
- package/src/ui/edit-entry-dialog.tsx +68 -104
- package/src/ui/editor-switcher.tsx +9 -16
- package/src/ui/entry-detail-dialog.tsx +62 -181
- package/src/ui/format-value.tsx +25 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +72 -109
- package/src/ui/large-value-viewer-state.ts +25 -0
- package/src/ui/large-value-viewer.tsx +65 -0
- package/src/ui/panel.tsx +649 -509
- package/src/ui/query-client.tsx +23 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/type-conversion.ts +2 -5
- package/src/ui/typed-value-editor.tsx +15 -20
- package/src/ui/use-storage-entries.ts +192 -0
- package/src/ui/utils.ts +1 -4
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- package/tailwind.config.ts +0 -94
package/src/ui/import-dialog.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Badge, Button, Dialog } from '@rozenite/ui';
|
|
2
|
+
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
|
2
3
|
import type { StorageEntry, StorageTarget } from '../shared/types';
|
|
3
4
|
import type { ImportPreview, StorageSnapshotV1 } from '../shared/snapshot';
|
|
4
5
|
|
|
@@ -13,6 +14,7 @@ export type ImportFlightState =
|
|
|
13
14
|
}
|
|
14
15
|
| {
|
|
15
16
|
phase: 'importing';
|
|
17
|
+
requestId: string;
|
|
16
18
|
target: StorageTarget;
|
|
17
19
|
total: number;
|
|
18
20
|
written: number;
|
|
@@ -42,10 +44,10 @@ const KeyList = ({ title, items }: { title: string; items: string[] }) => {
|
|
|
42
44
|
if (items.length === 0) return null;
|
|
43
45
|
return (
|
|
44
46
|
<div>
|
|
45
|
-
<div className="text-xs font-medium text-
|
|
47
|
+
<div className="text-xs font-medium text-foreground">
|
|
46
48
|
{title} ({items.length})
|
|
47
49
|
</div>
|
|
48
|
-
<div className="mt-1 max-h-24 overflow-auto rounded bg-
|
|
50
|
+
<div className="mt-1 max-h-24 overflow-auto rounded-md bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
|
|
49
51
|
{items.slice(0, 50).join(', ')}
|
|
50
52
|
{items.length > 50 ? `, +${items.length - 50} more` : ''}
|
|
51
53
|
</div>
|
|
@@ -53,6 +55,13 @@ const KeyList = ({ title, items }: { title: string; items: string[] }) => {
|
|
|
53
55
|
);
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
const StatTile = ({ label, value }: { label: string; value: number }) => (
|
|
59
|
+
<div className="rounded-md bg-muted p-2">
|
|
60
|
+
<div className="text-muted-foreground">{label}</div>
|
|
61
|
+
<div className="text-base font-semibold text-foreground">{value}</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
|
|
56
65
|
const PreviewBody = ({
|
|
57
66
|
state,
|
|
58
67
|
onApply,
|
|
@@ -68,55 +77,35 @@ const PreviewBody = ({
|
|
|
68
77
|
|
|
69
78
|
return (
|
|
70
79
|
<>
|
|
71
|
-
<div className="
|
|
80
|
+
<div className="flex flex-col gap-3">
|
|
72
81
|
{preview.metadataMismatch && (
|
|
73
|
-
<div className="flex items-start gap-2 rounded border border-
|
|
74
|
-
<AlertTriangle className="mt-0.5 h-4 w-4
|
|
82
|
+
<div className="flex items-start gap-2 rounded-md border border-border bg-muted p-2 text-xs text-foreground">
|
|
83
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
75
84
|
<div>
|
|
76
|
-
This file was exported from <strong>{sourceLabel}</strong>. You
|
|
77
|
-
|
|
85
|
+
This file was exported from <strong>{sourceLabel}</strong>. You are importing into{' '}
|
|
86
|
+
<strong>{targetLabel}</strong>.
|
|
78
87
|
</div>
|
|
79
88
|
</div>
|
|
80
89
|
)}
|
|
81
90
|
|
|
82
91
|
{hasUnsupported && (
|
|
83
|
-
<div className="flex items-start gap-2 rounded border border-
|
|
84
|
-
<XCircle className="mt-0.5 h-4 w-4
|
|
92
|
+
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
|
|
93
|
+
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
85
94
|
<div>
|
|
86
95
|
{preview.unsupportedTypes.length}{' '}
|
|
87
|
-
{preview.unsupportedTypes.length === 1
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
again.
|
|
92
|
-
<div className="mt-1 max-h-20 overflow-auto rounded bg-gray-900 px-2 py-1 font-mono text-xs text-red-100">
|
|
93
|
-
{preview.unsupportedTypes
|
|
94
|
-
.map((u) => `${u.key} (${u.type})`)
|
|
95
|
-
.join(', ')}
|
|
96
|
+
{preview.unsupportedTypes.length === 1 ? 'entry has a type' : 'entries have types'}{' '}
|
|
97
|
+
not supported by this storage. Remove them from the file and try again.
|
|
98
|
+
<div className="mt-1 max-h-20 overflow-auto rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
99
|
+
{preview.unsupportedTypes.map((u) => `${u.key} (${u.type})`).join(', ')}
|
|
96
100
|
</div>
|
|
97
101
|
</div>
|
|
98
102
|
</div>
|
|
99
103
|
)}
|
|
100
104
|
|
|
101
105
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{preview.newKeys.length}
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
<div className="rounded bg-gray-900 p-2">
|
|
109
|
-
<div className="text-gray-400">Overwrite</div>
|
|
110
|
-
<div className="text-base font-semibold text-yellow-400">
|
|
111
|
-
{preview.overwriteKeys.length}
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
<div className="rounded bg-gray-900 p-2">
|
|
115
|
-
<div className="text-gray-400">Skipped</div>
|
|
116
|
-
<div className="text-base font-semibold text-gray-400">
|
|
117
|
-
{preview.skippedKeys.length}
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
106
|
+
<StatTile label="New" value={preview.newKeys.length} />
|
|
107
|
+
<StatTile label="Overwrite" value={preview.overwriteKeys.length} />
|
|
108
|
+
<StatTile label="Skipped" value={preview.skippedKeys.length} />
|
|
120
109
|
</div>
|
|
121
110
|
|
|
122
111
|
<KeyList title="New keys" items={preview.newKeys} />
|
|
@@ -127,21 +116,14 @@ const PreviewBody = ({
|
|
|
127
116
|
/>
|
|
128
117
|
</div>
|
|
129
118
|
|
|
130
|
-
<
|
|
131
|
-
<
|
|
132
|
-
onClick={onCancel}
|
|
133
|
-
className="rounded px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-700 hover:text-white"
|
|
134
|
-
>
|
|
119
|
+
<Dialog.Footer>
|
|
120
|
+
<Button variant="outline" onClick={onCancel}>
|
|
135
121
|
Cancel
|
|
136
|
-
</
|
|
137
|
-
<
|
|
138
|
-
onClick={onApply}
|
|
139
|
-
disabled={hasUnsupported}
|
|
140
|
-
className="rounded bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-600"
|
|
141
|
-
>
|
|
122
|
+
</Button>
|
|
123
|
+
<Button onClick={onApply} disabled={hasUnsupported}>
|
|
142
124
|
Apply ({entriesToWrite.length})
|
|
143
|
-
</
|
|
144
|
-
</
|
|
125
|
+
</Button>
|
|
126
|
+
</Dialog.Footer>
|
|
145
127
|
</>
|
|
146
128
|
);
|
|
147
129
|
};
|
|
@@ -152,8 +134,8 @@ const ImportingBody = ({
|
|
|
152
134
|
state: Extract<ImportFlightState, { phase: 'importing' }>;
|
|
153
135
|
}) => (
|
|
154
136
|
<div className="flex flex-col items-center gap-3 py-6">
|
|
155
|
-
<Loader2 className="h-6 w-6 animate-spin text-
|
|
156
|
-
<div className="text-sm text-
|
|
137
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
138
|
+
<div className="text-sm text-foreground">
|
|
157
139
|
Importing… {state.written} / {state.total}
|
|
158
140
|
</div>
|
|
159
141
|
</div>
|
|
@@ -169,93 +151,74 @@ const ResultBody = ({
|
|
|
169
151
|
<>
|
|
170
152
|
{state.ok ? (
|
|
171
153
|
<div className="flex flex-col items-center gap-3 py-4">
|
|
172
|
-
<CheckCircle2 className="h-8 w-8 text-
|
|
173
|
-
<div className="text-sm text-
|
|
154
|
+
<CheckCircle2 className="h-8 w-8 text-primary" />
|
|
155
|
+
<div className="text-sm text-foreground">
|
|
174
156
|
Imported {state.written} {state.written === 1 ? 'entry' : 'entries'}.
|
|
175
157
|
</div>
|
|
176
158
|
</div>
|
|
177
159
|
) : (
|
|
178
|
-
<div className="
|
|
179
|
-
<div className="flex items-center gap-2 text-
|
|
160
|
+
<div className="flex flex-col gap-2 py-2">
|
|
161
|
+
<div className="flex items-center gap-2 text-destructive">
|
|
180
162
|
<XCircle className="h-5 w-5" />
|
|
181
163
|
<div className="text-sm font-medium">
|
|
182
164
|
Import failed after {state.written} of {state.total}.
|
|
183
165
|
</div>
|
|
184
166
|
</div>
|
|
185
167
|
{state.failedKey && (
|
|
186
|
-
<div className="text-xs text-
|
|
168
|
+
<div className="text-xs text-muted-foreground">
|
|
187
169
|
Failed at key:{' '}
|
|
188
|
-
<
|
|
170
|
+
<Badge variant="outline" className="font-mono">
|
|
171
|
+
{state.failedKey}
|
|
172
|
+
</Badge>
|
|
189
173
|
</div>
|
|
190
174
|
)}
|
|
191
|
-
<div className="rounded bg-
|
|
175
|
+
<div className="rounded-md bg-muted px-2 py-1 font-mono text-xs text-destructive">
|
|
192
176
|
{state.error}
|
|
193
177
|
</div>
|
|
194
178
|
</div>
|
|
195
179
|
)}
|
|
196
180
|
|
|
197
|
-
<
|
|
198
|
-
<
|
|
199
|
-
onClick={onClose}
|
|
200
|
-
autoFocus
|
|
201
|
-
className="rounded bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
202
|
-
>
|
|
181
|
+
<Dialog.Footer>
|
|
182
|
+
<Button onClick={onClose} autoFocus>
|
|
203
183
|
Close
|
|
204
|
-
</
|
|
205
|
-
</
|
|
184
|
+
</Button>
|
|
185
|
+
</Dialog.Footer>
|
|
206
186
|
</>
|
|
207
187
|
);
|
|
208
188
|
|
|
209
|
-
export const ImportDialog = ({
|
|
210
|
-
state,
|
|
211
|
-
onApply,
|
|
212
|
-
onCancel,
|
|
213
|
-
onClose,
|
|
214
|
-
}: ImportDialogProps) => {
|
|
215
|
-
if (!state) return null;
|
|
216
|
-
|
|
189
|
+
export const ImportDialog = ({ state, onApply, onCancel, onClose }: ImportDialogProps) => {
|
|
217
190
|
const title =
|
|
218
|
-
state
|
|
219
|
-
? '
|
|
220
|
-
: state.phase === '
|
|
221
|
-
? '
|
|
222
|
-
: state.
|
|
223
|
-
? '
|
|
224
|
-
:
|
|
191
|
+
state === null
|
|
192
|
+
? ''
|
|
193
|
+
: state.phase === 'preview'
|
|
194
|
+
? 'Import snapshot'
|
|
195
|
+
: state.phase === 'importing'
|
|
196
|
+
? 'Importing…'
|
|
197
|
+
: state.ok
|
|
198
|
+
? 'Import complete'
|
|
199
|
+
: 'Import failed';
|
|
225
200
|
|
|
226
201
|
// Importing phase: dialog is locked; only the success/failure transition closes it.
|
|
227
|
-
const isDismissible = state.phase !== 'importing';
|
|
202
|
+
const isDismissible = state !== null && state.phase !== 'importing';
|
|
228
203
|
|
|
229
204
|
return (
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
205
|
+
<Dialog
|
|
206
|
+
open={state !== null}
|
|
207
|
+
onOpenChange={(open) => {
|
|
208
|
+
if (!open && isDismissible) onClose();
|
|
209
|
+
}}
|
|
233
210
|
>
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
<div className="mb-4 flex items-center justify-between">
|
|
239
|
-
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
|
240
|
-
{isDismissible && (
|
|
241
|
-
<button
|
|
242
|
-
onClick={onClose}
|
|
243
|
-
className="rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-gray-200"
|
|
244
|
-
title="Close dialog"
|
|
245
|
-
>
|
|
246
|
-
<X className="h-4 w-4" />
|
|
247
|
-
</button>
|
|
248
|
-
)}
|
|
249
|
-
</div>
|
|
211
|
+
<Dialog.Content showCloseButton={isDismissible}>
|
|
212
|
+
<Dialog.Header>
|
|
213
|
+
<Dialog.Title>{title}</Dialog.Title>
|
|
214
|
+
</Dialog.Header>
|
|
250
215
|
|
|
251
|
-
{state
|
|
216
|
+
{state?.phase === 'preview' && (
|
|
252
217
|
<PreviewBody state={state} onApply={onApply} onCancel={onCancel} />
|
|
253
218
|
)}
|
|
254
|
-
{state
|
|
255
|
-
{state
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
219
|
+
{state?.phase === 'importing' && <ImportingBody state={state} />}
|
|
220
|
+
{state?.phase === 'result' && <ResultBody state={state} onClose={onClose} />}
|
|
221
|
+
</Dialog.Content>
|
|
222
|
+
</Dialog>
|
|
260
223
|
);
|
|
261
224
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const VIRTUALIZED_TEXT_THRESHOLD = 50_000;
|
|
2
|
+
export const TEXT_CHUNK_SIZE = 2_000;
|
|
3
|
+
|
|
4
|
+
export type TextRange = readonly [start: number, end: number];
|
|
5
|
+
|
|
6
|
+
// Keep an index of row boundaries instead of splitting the complete value.
|
|
7
|
+
// This lets the virtualizer create text only for visible rows, including a
|
|
8
|
+
// single pathological line with no newline boundaries.
|
|
9
|
+
export const textRowRanges = (text: string): TextRange[] => {
|
|
10
|
+
const ranges: TextRange[] = [];
|
|
11
|
+
let start = 0;
|
|
12
|
+
|
|
13
|
+
while (start < text.length) {
|
|
14
|
+
const newline = text.indexOf('\n', start);
|
|
15
|
+
const lineEnd = newline === -1 ? text.length : newline + 1;
|
|
16
|
+
|
|
17
|
+
for (let chunkStart = start; chunkStart < lineEnd; chunkStart += TEXT_CHUNK_SIZE) {
|
|
18
|
+
ranges.push([chunkStart, Math.min(chunkStart + TEXT_CHUNK_SIZE, lineEnd)]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
start = lineEnd;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return ranges.length === 0 ? [[0, 0]] : ranges;
|
|
25
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Virtuoso } from 'react-virtuoso';
|
|
3
|
+
import { formatHexdumpRow } from './binary';
|
|
4
|
+
import { textRowRanges, VIRTUALIZED_TEXT_THRESHOLD } from './large-value-viewer-state';
|
|
5
|
+
|
|
6
|
+
export const TextValueViewer = ({ value }: { value: string }) => {
|
|
7
|
+
const ranges = useMemo(
|
|
8
|
+
() => (value.length > VIRTUALIZED_TEXT_THRESHOLD ? textRowRanges(value) : null),
|
|
9
|
+
[value],
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
if (!ranges) {
|
|
13
|
+
return (
|
|
14
|
+
<pre className="font-mono text-sm text-success whitespace-pre-wrap wrap-anywhere">
|
|
15
|
+
"{value}"
|
|
16
|
+
</pre>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Virtuoso
|
|
22
|
+
aria-label="Large string value"
|
|
23
|
+
className="font-mono text-sm text-success"
|
|
24
|
+
style={{ height: 384 }}
|
|
25
|
+
totalCount={ranges.length}
|
|
26
|
+
itemContent={(index) => {
|
|
27
|
+
const [start, end] = ranges[index];
|
|
28
|
+
return (
|
|
29
|
+
<div className="whitespace-pre-wrap wrap-anywhere">
|
|
30
|
+
{index === 0 ? '"' : ''}
|
|
31
|
+
{value.slice(start, end)}
|
|
32
|
+
{index === ranges.length - 1 ? '"' : ''}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const HexdumpValueViewer = ({ bytes }: { bytes: readonly number[] }) => {
|
|
41
|
+
const totalCount = Math.ceil(bytes.length / 16);
|
|
42
|
+
|
|
43
|
+
if (totalCount === 0) {
|
|
44
|
+
return <div className="text-xs text-muted italic">No bytes to display.</div>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Virtuoso
|
|
49
|
+
aria-label="Buffer hexdump"
|
|
50
|
+
className="font-mono text-xs text-accent"
|
|
51
|
+
style={{ height: 384 }}
|
|
52
|
+
totalCount={totalCount}
|
|
53
|
+
itemContent={(index) => {
|
|
54
|
+
const row = formatHexdumpRow(bytes, index * 16);
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex gap-4 leading-snug whitespace-pre">
|
|
57
|
+
<span className="text-muted">{row.offset}</span>
|
|
58
|
+
<span>{row.hex}</span>
|
|
59
|
+
<span className="text-muted">|{row.ascii}|</span>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
};
|