@rozenite/storage-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.
- package/CHANGELOG.md +34 -0
- package/README.md +23 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +43 -57
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
- package/dist/react-native/index.d.ts +29 -6
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +1 -0
- package/package.json +9 -6
- package/src/react-native/__tests__/import.test.ts +182 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
- package/src/react-native/adapters/mmkv.ts +65 -39
- package/src/react-native/import.ts +67 -0
- package/src/react-native/storage-view.ts +16 -8
- package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
- package/src/shared/__tests__/snapshot.test.ts +361 -0
- package/src/shared/messaging.ts +25 -1
- package/src/shared/snapshot.ts +276 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/import-dialog.tsx +261 -0
- package/src/ui/panel.tsx +257 -57
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/src/ui/utils.ts +30 -0
- package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
- package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
package/src/ui/panel.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
|
|
2
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
-
import {
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Download, Plus, Search, Upload } from 'lucide-react';
|
|
4
4
|
import type {
|
|
5
5
|
StorageDeleteEntryEvent,
|
|
6
6
|
StorageEventMap,
|
|
7
|
+
StorageImportResultEvent,
|
|
7
8
|
StorageSetEntryEvent,
|
|
8
9
|
StorageSnapshotEvent,
|
|
9
10
|
} from '../shared/messaging';
|
|
@@ -14,10 +15,18 @@ import type {
|
|
|
14
15
|
StorageTarget,
|
|
15
16
|
} from '../shared/types';
|
|
16
17
|
import { getStorageViewId } from '../shared/types';
|
|
18
|
+
import {
|
|
19
|
+
buildSnapshot,
|
|
20
|
+
computePreview,
|
|
21
|
+
parseSnapshot,
|
|
22
|
+
} from '../shared/snapshot';
|
|
17
23
|
import { EditableTable } from './editable-table';
|
|
18
24
|
import { AddEntryDialog } from './add-entry-dialog';
|
|
19
25
|
import { EntryDetailDialog } from './entry-detail-dialog';
|
|
20
26
|
import { EditEntryDialog } from './edit-entry-dialog';
|
|
27
|
+
import { ConfirmDialog } from './confirm-dialog';
|
|
28
|
+
import { ImportDialog, type ImportFlightState } from './import-dialog';
|
|
29
|
+
import { buildExportFilename, downloadJson } from './utils';
|
|
21
30
|
import './globals.css';
|
|
22
31
|
|
|
23
32
|
type StorageSnapshotState = {
|
|
@@ -25,10 +34,22 @@ type StorageSnapshotState = {
|
|
|
25
34
|
adapterName: string;
|
|
26
35
|
storageName: string;
|
|
27
36
|
capabilities: StorageCapabilities;
|
|
37
|
+
blacklist?: RegExp;
|
|
28
38
|
entries: StorageEntry[];
|
|
29
39
|
};
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
type AlertState = {
|
|
42
|
+
isOpen: boolean;
|
|
43
|
+
title: string;
|
|
44
|
+
message: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sameTarget = (a: StorageTarget, b: StorageTarget) =>
|
|
48
|
+
a.adapterId === b.adapterId && a.storageId === b.storageId;
|
|
49
|
+
|
|
50
|
+
const getEntryTypeFromValue = (
|
|
51
|
+
value: StorageEntryValue,
|
|
52
|
+
): StorageEntry['type'] => {
|
|
32
53
|
if (typeof value === 'string') {
|
|
33
54
|
return 'string';
|
|
34
55
|
}
|
|
@@ -46,11 +67,11 @@ const getEntryTypeFromValue = (value: StorageEntryValue): StorageEntry['type'] =
|
|
|
46
67
|
|
|
47
68
|
export default function StoragePanel() {
|
|
48
69
|
const [snapshots, setSnapshots] = useState<Map<string, StorageSnapshotState>>(
|
|
49
|
-
new Map()
|
|
50
|
-
);
|
|
51
|
-
const [selectedStorageViewId, setSelectedStorageViewId] = useState<string | null>(
|
|
52
|
-
null
|
|
70
|
+
new Map(),
|
|
53
71
|
);
|
|
72
|
+
const [selectedStorageViewId, setSelectedStorageViewId] = useState<
|
|
73
|
+
string | null
|
|
74
|
+
>(null);
|
|
54
75
|
const [loading, setLoading] = useState(false);
|
|
55
76
|
const [searchTerm, setSearchTerm] = useState('');
|
|
56
77
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
@@ -58,6 +79,15 @@ export default function StoragePanel() {
|
|
|
58
79
|
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
|
59
80
|
const [editingEntry, setEditingEntry] = useState<StorageEntry | null>(null);
|
|
60
81
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
|
82
|
+
const [importFlight, setImportFlight] = useState<ImportFlightState | null>(
|
|
83
|
+
null,
|
|
84
|
+
);
|
|
85
|
+
const [alertState, setAlertState] = useState<AlertState>({
|
|
86
|
+
isOpen: false,
|
|
87
|
+
title: '',
|
|
88
|
+
message: '',
|
|
89
|
+
});
|
|
90
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
61
91
|
|
|
62
92
|
const client = useRozeniteDevToolsClient<StorageEventMap>({
|
|
63
93
|
pluginId: '@rozenite/storage-plugin',
|
|
@@ -71,61 +101,91 @@ export default function StoragePanel() {
|
|
|
71
101
|
const snapshotSubscription = client.onMessage(
|
|
72
102
|
'snapshot',
|
|
73
103
|
(event: StorageSnapshotEvent) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
const viewId = getStorageViewId(event.target);
|
|
105
|
+
setSnapshots((previous) => {
|
|
106
|
+
const next = new Map(previous);
|
|
107
|
+
next.set(viewId, {
|
|
108
|
+
target: event.target,
|
|
109
|
+
adapterName: event.adapterName,
|
|
110
|
+
storageName: event.storageName,
|
|
111
|
+
capabilities: event.capabilities,
|
|
112
|
+
blacklist: event.blacklist
|
|
113
|
+
? new RegExp(event.blacklist.source, event.blacklist.flags)
|
|
114
|
+
: undefined,
|
|
115
|
+
entries: event.entries,
|
|
116
|
+
});
|
|
84
117
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
if (previous.size === 0 && !selectedStorageViewId) {
|
|
119
|
+
setSelectedStorageViewId(viewId);
|
|
120
|
+
}
|
|
88
121
|
|
|
89
|
-
|
|
90
|
-
|
|
122
|
+
return next;
|
|
123
|
+
});
|
|
91
124
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
125
|
+
if (viewId === selectedStorageViewId) {
|
|
126
|
+
setLoading(false);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
96
129
|
);
|
|
97
130
|
|
|
98
131
|
const setEntrySubscription = client.onMessage(
|
|
99
132
|
'set-entry',
|
|
100
133
|
(event: StorageSetEntryEvent) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
const viewId = getStorageViewId(event.target);
|
|
135
|
+
setSnapshots((previous) => {
|
|
136
|
+
const next = new Map(previous);
|
|
137
|
+
const current = next.get(viewId);
|
|
105
138
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
139
|
+
if (!current) {
|
|
140
|
+
return previous;
|
|
141
|
+
}
|
|
109
142
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
const existingIndex = current.entries.findIndex(
|
|
144
|
+
(entry) => entry.key === event.entry.key,
|
|
145
|
+
);
|
|
113
146
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
147
|
+
const entries =
|
|
148
|
+
existingIndex >= 0
|
|
149
|
+
? current.entries.map((entry) =>
|
|
150
|
+
entry.key === event.entry.key ? event.entry : entry,
|
|
151
|
+
)
|
|
152
|
+
: [...current.entries, event.entry];
|
|
120
153
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
next.set(viewId, {
|
|
155
|
+
...current,
|
|
156
|
+
entries,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return next;
|
|
124
160
|
});
|
|
125
161
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
162
|
+
setImportFlight((previous) => {
|
|
163
|
+
if (!previous || previous.phase !== 'importing') return previous;
|
|
164
|
+
if (!sameTarget(event.target, previous.target)) return previous;
|
|
165
|
+
return { ...previous, written: previous.written + 1 };
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const importResultSubscription = client.onMessage(
|
|
171
|
+
'import-result',
|
|
172
|
+
(event: StorageImportResultEvent) => {
|
|
173
|
+
setImportFlight((previous) => {
|
|
174
|
+
if (!previous || previous.phase !== 'importing') return previous;
|
|
175
|
+
if (!sameTarget(event.target, previous.target)) return previous;
|
|
176
|
+
if (event.ok) {
|
|
177
|
+
return { phase: 'result', ok: true, written: event.written };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
phase: 'result',
|
|
181
|
+
ok: false,
|
|
182
|
+
written: event.written,
|
|
183
|
+
total: event.total,
|
|
184
|
+
failedKey: event.failedKey,
|
|
185
|
+
error: event.error ?? 'Unknown error',
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
},
|
|
129
189
|
);
|
|
130
190
|
|
|
131
191
|
const deleteEntrySubscription = client.onMessage(
|
|
@@ -148,7 +208,7 @@ export default function StoragePanel() {
|
|
|
148
208
|
|
|
149
209
|
return next;
|
|
150
210
|
});
|
|
151
|
-
}
|
|
211
|
+
},
|
|
152
212
|
);
|
|
153
213
|
|
|
154
214
|
client.send('get-snapshot', {
|
|
@@ -160,6 +220,7 @@ export default function StoragePanel() {
|
|
|
160
220
|
snapshotSubscription.remove();
|
|
161
221
|
setEntrySubscription.remove();
|
|
162
222
|
deleteEntrySubscription.remove();
|
|
223
|
+
importResultSubscription.remove();
|
|
163
224
|
};
|
|
164
225
|
}, [client, selectedStorageViewId]);
|
|
165
226
|
|
|
@@ -178,7 +239,7 @@ export default function StoragePanel() {
|
|
|
178
239
|
const separatorIndex = selectedStorageViewId.indexOf(':');
|
|
179
240
|
if (separatorIndex < 0) {
|
|
180
241
|
console.warn(
|
|
181
|
-
`[Rozenite] Storage Plugin: Invalid storage view id "${selectedStorageViewId}"
|
|
242
|
+
`[Rozenite] Storage Plugin: Invalid storage view id "${selectedStorageViewId}".`,
|
|
182
243
|
);
|
|
183
244
|
setLoading(false);
|
|
184
245
|
return;
|
|
@@ -198,7 +259,7 @@ export default function StoragePanel() {
|
|
|
198
259
|
}, [client, selectedStorageViewId, snapshots]);
|
|
199
260
|
|
|
200
261
|
const selectedStorage = selectedStorageViewId
|
|
201
|
-
? snapshots.get(selectedStorageViewId) ?? null
|
|
262
|
+
? (snapshots.get(selectedStorageViewId) ?? null)
|
|
202
263
|
: null;
|
|
203
264
|
|
|
204
265
|
const entries = selectedStorage?.entries ?? [];
|
|
@@ -206,15 +267,15 @@ export default function StoragePanel() {
|
|
|
206
267
|
const filteredEntries = useMemo(
|
|
207
268
|
() =>
|
|
208
269
|
entries.filter((entry) =>
|
|
209
|
-
entry.key.toLowerCase().includes(searchTerm.toLowerCase())
|
|
270
|
+
entry.key.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
210
271
|
),
|
|
211
|
-
[entries, searchTerm]
|
|
272
|
+
[entries, searchTerm],
|
|
212
273
|
);
|
|
213
274
|
|
|
214
275
|
const supportedTypes = selectedStorage?.capabilities.supportedTypes ?? [];
|
|
215
276
|
|
|
216
277
|
const updateEntriesForSelectedStorage = (
|
|
217
|
-
mutate: (entries: StorageEntry[]) => StorageEntry[]
|
|
278
|
+
mutate: (entries: StorageEntry[]) => StorageEntry[],
|
|
218
279
|
) => {
|
|
219
280
|
if (!selectedStorageViewId) {
|
|
220
281
|
return;
|
|
@@ -266,7 +327,7 @@ export default function StoragePanel() {
|
|
|
266
327
|
});
|
|
267
328
|
|
|
268
329
|
updateEntriesForSelectedStorage((currentEntries) =>
|
|
269
|
-
currentEntries.map((entry) => (entry.key === key ? updatedEntry : entry))
|
|
330
|
+
currentEntries.map((entry) => (entry.key === key ? updatedEntry : entry)),
|
|
270
331
|
);
|
|
271
332
|
};
|
|
272
333
|
|
|
@@ -282,7 +343,7 @@ export default function StoragePanel() {
|
|
|
282
343
|
});
|
|
283
344
|
|
|
284
345
|
updateEntriesForSelectedStorage((currentEntries) =>
|
|
285
|
-
currentEntries.filter((entry) => entry.key !== key)
|
|
346
|
+
currentEntries.filter((entry) => entry.key !== key),
|
|
286
347
|
);
|
|
287
348
|
};
|
|
288
349
|
|
|
@@ -297,7 +358,105 @@ export default function StoragePanel() {
|
|
|
297
358
|
entry,
|
|
298
359
|
});
|
|
299
360
|
|
|
300
|
-
updateEntriesForSelectedStorage((currentEntries) => [
|
|
361
|
+
updateEntriesForSelectedStorage((currentEntries) => [
|
|
362
|
+
...currentEntries,
|
|
363
|
+
entry,
|
|
364
|
+
]);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const showAlert = (title: string, message: string) =>
|
|
368
|
+
setAlertState({ isOpen: true, title, message });
|
|
369
|
+
|
|
370
|
+
const handleImportClick = () => {
|
|
371
|
+
if (!fileInputRef.current) return;
|
|
372
|
+
fileInputRef.current.value = '';
|
|
373
|
+
fileInputRef.current.click();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const handleFileChange = async (
|
|
377
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
378
|
+
) => {
|
|
379
|
+
const file = event.target.files?.[0];
|
|
380
|
+
if (!file || !selectedStorage) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let raw: unknown;
|
|
385
|
+
try {
|
|
386
|
+
const text = await file.text();
|
|
387
|
+
raw = JSON.parse(text);
|
|
388
|
+
} catch (parseError) {
|
|
389
|
+
showAlert(
|
|
390
|
+
'Could not read file',
|
|
391
|
+
parseError instanceof Error ? parseError.message : String(parseError),
|
|
392
|
+
);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const parsed = parseSnapshot(raw);
|
|
397
|
+
if (!parsed.ok) {
|
|
398
|
+
showAlert(
|
|
399
|
+
'Invalid snapshot',
|
|
400
|
+
`${parsed.error.path}: ${parsed.error.message}`,
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const preview = computePreview(parsed.snapshot, {
|
|
406
|
+
target: selectedStorage.target,
|
|
407
|
+
capabilities: selectedStorage.capabilities,
|
|
408
|
+
entryKeys: new Set(selectedStorage.entries.map((entry) => entry.key)),
|
|
409
|
+
isBlacklisted: selectedStorage.blacklist
|
|
410
|
+
? (key) => selectedStorage.blacklist!.test(key)
|
|
411
|
+
: () => false,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const skippedSet = new Set(preview.skippedKeys.map((s) => s.key));
|
|
415
|
+
const unsupportedSet = new Set(preview.unsupportedTypes.map((u) => u.key));
|
|
416
|
+
const entriesToWrite = parsed.snapshot.entries.filter(
|
|
417
|
+
(entry) => !skippedSet.has(entry.key) && !unsupportedSet.has(entry.key),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
setImportFlight({
|
|
421
|
+
phase: 'preview',
|
|
422
|
+
target: selectedStorage.target,
|
|
423
|
+
targetLabel: `${selectedStorage.adapterName} / ${selectedStorage.storageName}`,
|
|
424
|
+
snapshot: parsed.snapshot,
|
|
425
|
+
preview,
|
|
426
|
+
entriesToWrite,
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const handleApplyImport = () => {
|
|
431
|
+
if (!client) return;
|
|
432
|
+
if (!importFlight || importFlight.phase !== 'preview') return;
|
|
433
|
+
|
|
434
|
+
client.send('import-entries', {
|
|
435
|
+
type: 'import-entries',
|
|
436
|
+
target: importFlight.target,
|
|
437
|
+
entries: importFlight.entriesToWrite,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
setImportFlight({
|
|
441
|
+
phase: 'importing',
|
|
442
|
+
target: importFlight.target,
|
|
443
|
+
total: importFlight.entriesToWrite.length,
|
|
444
|
+
written: 0,
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const handleCloseImport = () => setImportFlight(null);
|
|
449
|
+
|
|
450
|
+
const handleExport = () => {
|
|
451
|
+
if (!selectedStorage) return;
|
|
452
|
+
const snapshot = buildSnapshot({
|
|
453
|
+
target: selectedStorage.target,
|
|
454
|
+
adapterName: selectedStorage.adapterName,
|
|
455
|
+
storageName: selectedStorage.storageName,
|
|
456
|
+
capabilities: selectedStorage.capabilities,
|
|
457
|
+
entries: selectedStorage.entries,
|
|
458
|
+
});
|
|
459
|
+
downloadJson(snapshot, buildExportFilename(selectedStorage.target));
|
|
301
460
|
};
|
|
302
461
|
|
|
303
462
|
const storageOptions = [...snapshots.entries()].map(([viewId, snapshot]) => ({
|
|
@@ -346,6 +505,31 @@ export default function StoragePanel() {
|
|
|
346
505
|
<Plus className="h-3 w-3" />
|
|
347
506
|
Add Entry
|
|
348
507
|
</button>
|
|
508
|
+
<button
|
|
509
|
+
onClick={handleImportClick}
|
|
510
|
+
disabled={!selectedStorage}
|
|
511
|
+
className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
|
|
512
|
+
title="Import entries from a JSON snapshot"
|
|
513
|
+
>
|
|
514
|
+
<Upload className="h-3 w-3" />
|
|
515
|
+
Import
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
onClick={handleExport}
|
|
519
|
+
disabled={!selectedStorage || entries.length === 0}
|
|
520
|
+
className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
|
|
521
|
+
title="Export entries to a JSON snapshot"
|
|
522
|
+
>
|
|
523
|
+
<Download className="h-3 w-3" />
|
|
524
|
+
Export
|
|
525
|
+
</button>
|
|
526
|
+
<input
|
|
527
|
+
ref={fileInputRef}
|
|
528
|
+
type="file"
|
|
529
|
+
accept="application/json,.json"
|
|
530
|
+
className="hidden"
|
|
531
|
+
onChange={handleFileChange}
|
|
532
|
+
/>
|
|
349
533
|
<div className="flex-1">
|
|
350
534
|
<div className="relative">
|
|
351
535
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
@@ -437,6 +621,22 @@ export default function StoragePanel() {
|
|
|
437
621
|
supportedTypes={supportedTypes}
|
|
438
622
|
entry={editingEntry}
|
|
439
623
|
/>
|
|
624
|
+
|
|
625
|
+
<ImportDialog
|
|
626
|
+
state={importFlight}
|
|
627
|
+
onApply={handleApplyImport}
|
|
628
|
+
onCancel={handleCloseImport}
|
|
629
|
+
onClose={handleCloseImport}
|
|
630
|
+
/>
|
|
631
|
+
|
|
632
|
+
<ConfirmDialog
|
|
633
|
+
isOpen={alertState.isOpen}
|
|
634
|
+
onClose={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
|
|
635
|
+
onConfirm={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
|
|
636
|
+
title={alertState.title}
|
|
637
|
+
message={alertState.message}
|
|
638
|
+
type="alert"
|
|
639
|
+
/>
|
|
440
640
|
</div>
|
|
441
641
|
);
|
|
442
642
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { StorageEntryType, StorageEntryValue } from '../shared/types';
|
|
2
|
+
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
5
|
+
|
|
6
|
+
// Decode bytes to a string. Returns the empty string when the bytes
|
|
7
|
+
// are not valid UTF-8 — used when the user switches a Hex editor's
|
|
8
|
+
// non-UTF-8 buffer back to a text editor, where there's no faithful
|
|
9
|
+
// representation to preserve.
|
|
10
|
+
const tryDecode = (bytes: readonly number[]): string => {
|
|
11
|
+
try {
|
|
12
|
+
return strictDecoder.decode(new Uint8Array(bytes));
|
|
13
|
+
} catch {
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const encode = (value: string): number[] =>
|
|
19
|
+
Array.from(textEncoder.encode(value));
|
|
20
|
+
|
|
21
|
+
// Convert a value between storage primitive types for the editor
|
|
22
|
+
// switcher. The headline transition is `string` ↔ `buffer`, which
|
|
23
|
+
// round-trips via UTF-8 — the user opens a string entry, picks Hex,
|
|
24
|
+
// and sees the bytes of that string. Other transitions preserve as
|
|
25
|
+
// much information as the destination type can carry, falling back to
|
|
26
|
+
// the destination's zero value when nothing meaningful remains.
|
|
27
|
+
export const convertValue = (
|
|
28
|
+
from: StorageEntryType,
|
|
29
|
+
to: StorageEntryType,
|
|
30
|
+
value: StorageEntryValue,
|
|
31
|
+
): StorageEntryValue => {
|
|
32
|
+
if (from === to) return value;
|
|
33
|
+
|
|
34
|
+
if (from === 'string') {
|
|
35
|
+
const str = value as string;
|
|
36
|
+
switch (to) {
|
|
37
|
+
case 'number': {
|
|
38
|
+
const n = Number(str);
|
|
39
|
+
return Number.isNaN(n) ? 0 : n;
|
|
40
|
+
}
|
|
41
|
+
case 'boolean':
|
|
42
|
+
return str === 'true';
|
|
43
|
+
case 'buffer':
|
|
44
|
+
return encode(str);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (from === 'number') {
|
|
49
|
+
const n = value as number;
|
|
50
|
+
switch (to) {
|
|
51
|
+
case 'string':
|
|
52
|
+
return String(n);
|
|
53
|
+
case 'boolean':
|
|
54
|
+
return n !== 0;
|
|
55
|
+
case 'buffer':
|
|
56
|
+
return encode(String(n));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (from === 'boolean') {
|
|
61
|
+
const b = value as boolean;
|
|
62
|
+
switch (to) {
|
|
63
|
+
case 'string':
|
|
64
|
+
return String(b);
|
|
65
|
+
case 'number':
|
|
66
|
+
return b ? 1 : 0;
|
|
67
|
+
case 'buffer':
|
|
68
|
+
return encode(String(b));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (from === 'buffer') {
|
|
73
|
+
const bytes = value as number[];
|
|
74
|
+
switch (to) {
|
|
75
|
+
case 'string':
|
|
76
|
+
return tryDecode(bytes);
|
|
77
|
+
case 'number': {
|
|
78
|
+
const decoded = tryDecode(bytes);
|
|
79
|
+
const n = Number(decoded);
|
|
80
|
+
return Number.isNaN(n) ? 0 : n;
|
|
81
|
+
}
|
|
82
|
+
case 'boolean':
|
|
83
|
+
return tryDecode(bytes) === 'true';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return value;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Zero value for a type — used when seeding the add-entry dialog and
|
|
91
|
+
// when conversion has no meaningful starting point.
|
|
92
|
+
export const defaultValueForType = (
|
|
93
|
+
type: StorageEntryType,
|
|
94
|
+
): StorageEntryValue => {
|
|
95
|
+
switch (type) {
|
|
96
|
+
case 'string':
|
|
97
|
+
return '';
|
|
98
|
+
case 'number':
|
|
99
|
+
return 0;
|
|
100
|
+
case 'boolean':
|
|
101
|
+
return false;
|
|
102
|
+
case 'buffer':
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { StorageEntryType, StorageEntryValue } from '../shared/types';
|
|
2
|
+
import { BinaryValueEditor } from './binary-value-editor';
|
|
3
|
+
import { EditorSwitcher } from './editor-switcher';
|
|
4
|
+
import { convertValue, defaultValueForType } from './type-conversion';
|
|
5
|
+
|
|
6
|
+
export type TypedValueEditorProps = {
|
|
7
|
+
supportedTypes: StorageEntryType[];
|
|
8
|
+
type: StorageEntryType;
|
|
9
|
+
// `null` signals "the current input is unsavable" — used by the hex
|
|
10
|
+
// editor for unparseable hex or empty input. Non-buffer types never
|
|
11
|
+
// emit null. Callers should disable Save when value is null.
|
|
12
|
+
value: StorageEntryValue | null;
|
|
13
|
+
onChange: (type: StorageEntryType, value: StorageEntryValue | null) => void;
|
|
14
|
+
// `id` is forwarded to the underlying input so callers can wire up
|
|
15
|
+
// <label htmlFor>. The hex editor (which has no single input) ignores
|
|
16
|
+
// it — there's no useful target.
|
|
17
|
+
inputId?: string;
|
|
18
|
+
autoFocus?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Renders the switcher + the type-specific value editor. Switching
|
|
22
|
+
// type runs `convertValue` so the user lands on a sensible starting
|
|
23
|
+
// point in the new editor instead of an empty field.
|
|
24
|
+
export const TypedValueEditor = ({
|
|
25
|
+
supportedTypes,
|
|
26
|
+
type,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
inputId,
|
|
30
|
+
autoFocus,
|
|
31
|
+
}: TypedValueEditorProps) => {
|
|
32
|
+
const handleTypeChange = (newType: StorageEntryType) => {
|
|
33
|
+
if (newType === type) return;
|
|
34
|
+
if (value === null) {
|
|
35
|
+
// Source value is currently unparseable — nothing meaningful to
|
|
36
|
+
// carry forward, land on the destination type's default.
|
|
37
|
+
onChange(newType, defaultValueForType(newType));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
onChange(newType, convertValue(type, newType, value));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
<EditorSwitcher
|
|
46
|
+
supportedTypes={supportedTypes}
|
|
47
|
+
value={type}
|
|
48
|
+
onChange={handleTypeChange}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
{type === 'buffer' ? (
|
|
52
|
+
<BinaryValueEditor
|
|
53
|
+
initialBytes={Array.isArray(value) ? value : undefined}
|
|
54
|
+
onChange={(bytes) => onChange('buffer', bytes)}
|
|
55
|
+
/>
|
|
56
|
+
) : type === 'boolean' ? (
|
|
57
|
+
<select
|
|
58
|
+
id={inputId}
|
|
59
|
+
value={String(value ?? false)}
|
|
60
|
+
onChange={(event) =>
|
|
61
|
+
onChange('boolean', event.target.value === 'true')
|
|
62
|
+
}
|
|
63
|
+
className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
64
|
+
autoFocus={autoFocus}
|
|
65
|
+
>
|
|
66
|
+
<option value="true">true</option>
|
|
67
|
+
<option value="false">false</option>
|
|
68
|
+
</select>
|
|
69
|
+
) : type === 'number' ? (
|
|
70
|
+
<input
|
|
71
|
+
id={inputId}
|
|
72
|
+
type="number"
|
|
73
|
+
value={String(value ?? '')}
|
|
74
|
+
onChange={(event) => {
|
|
75
|
+
const next = event.target.value;
|
|
76
|
+
const parsed = next === '' ? 0 : Number(next);
|
|
77
|
+
onChange('number', Number.isNaN(parsed) ? 0 : parsed);
|
|
78
|
+
}}
|
|
79
|
+
placeholder="Enter number value"
|
|
80
|
+
className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
81
|
+
autoFocus={autoFocus}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<input
|
|
85
|
+
id={inputId}
|
|
86
|
+
type="text"
|
|
87
|
+
value={String(value ?? '')}
|
|
88
|
+
onChange={(event) => onChange('string', event.target.value)}
|
|
89
|
+
placeholder="Enter string value"
|
|
90
|
+
className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
91
|
+
autoFocus={autoFocus}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
};
|
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { StorageTarget } from '../shared/types';
|
|
2
|
+
|
|
3
|
+
export const downloadJson = (data: unknown, filename: string): void => {
|
|
4
|
+
const json = JSON.stringify(data, null, 2);
|
|
5
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
6
|
+
const url = URL.createObjectURL(blob);
|
|
7
|
+
const link = document.createElement('a');
|
|
8
|
+
link.href = url;
|
|
9
|
+
link.download = filename;
|
|
10
|
+
document.body.appendChild(link);
|
|
11
|
+
link.click();
|
|
12
|
+
document.body.removeChild(link);
|
|
13
|
+
URL.revokeObjectURL(url);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
17
|
+
|
|
18
|
+
export const buildExportFilename = (
|
|
19
|
+
target: StorageTarget,
|
|
20
|
+
now: Date = new Date(),
|
|
21
|
+
): string => {
|
|
22
|
+
const yyyy = now.getFullYear().toString().padStart(4, '0');
|
|
23
|
+
const mm = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
24
|
+
const dd = now.getDate().toString().padStart(2, '0');
|
|
25
|
+
const hh = now.getHours().toString().padStart(2, '0');
|
|
26
|
+
const mi = now.getMinutes().toString().padStart(2, '0');
|
|
27
|
+
const ss = now.getSeconds().toString().padStart(2, '0');
|
|
28
|
+
const timestamp = `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
|
29
|
+
return `rozenite-storage-${sanitize(target.adapterId)}-${sanitize(target.storageId)}-${timestamp}.json`;
|
|
30
|
+
};
|