@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +23 -0
  3. package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
  4. package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/index.require.cjs +1 -1
  7. package/dist/react-native/chunks/index.require.js +43 -57
  8. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  9. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
  10. package/dist/react-native/index.d.ts +29 -6
  11. package/dist/rozenite.json +1 -1
  12. package/dist/sdk/index.d.ts +1 -0
  13. package/package.json +9 -6
  14. package/src/react-native/__tests__/import.test.ts +182 -0
  15. package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
  16. package/src/react-native/adapters/mmkv.ts +65 -39
  17. package/src/react-native/import.ts +67 -0
  18. package/src/react-native/storage-view.ts +16 -8
  19. package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
  20. package/src/shared/__tests__/snapshot.test.ts +361 -0
  21. package/src/shared/messaging.ts +25 -1
  22. package/src/shared/snapshot.ts +276 -0
  23. package/src/shared/types.ts +1 -0
  24. package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
  25. package/src/ui/__tests__/binary.test.ts +251 -0
  26. package/src/ui/__tests__/type-conversion.test.ts +123 -0
  27. package/src/ui/add-entry-dialog.tsx +83 -173
  28. package/src/ui/binary-value-editor-state.ts +125 -0
  29. package/src/ui/binary-value-editor.tsx +168 -0
  30. package/src/ui/binary.ts +123 -0
  31. package/src/ui/edit-entry-dialog.tsx +64 -161
  32. package/src/ui/editable-table.tsx +12 -10
  33. package/src/ui/editor-switcher.tsx +62 -0
  34. package/src/ui/entry-detail-dialog.tsx +14 -6
  35. package/src/ui/import-dialog.tsx +261 -0
  36. package/src/ui/panel.tsx +257 -57
  37. package/src/ui/type-conversion.ts +105 -0
  38. package/src/ui/typed-value-editor.tsx +96 -0
  39. package/src/ui/utils.ts +30 -0
  40. package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
  41. package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
  42. 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 { Search, Plus } from 'lucide-react';
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
- const getEntryTypeFromValue = (value: StorageEntryValue): StorageEntry['type'] => {
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
- const viewId = getStorageViewId(event.target);
75
- setSnapshots((previous) => {
76
- const next = new Map(previous);
77
- next.set(viewId, {
78
- target: event.target,
79
- adapterName: event.adapterName,
80
- storageName: event.storageName,
81
- capabilities: event.capabilities,
82
- entries: event.entries,
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
- if (previous.size === 0 && !selectedStorageViewId) {
86
- setSelectedStorageViewId(viewId);
87
- }
118
+ if (previous.size === 0 && !selectedStorageViewId) {
119
+ setSelectedStorageViewId(viewId);
120
+ }
88
121
 
89
- return next;
90
- });
122
+ return next;
123
+ });
91
124
 
92
- if (viewId === selectedStorageViewId) {
93
- setLoading(false);
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
- const viewId = getStorageViewId(event.target);
102
- setSnapshots((previous) => {
103
- const next = new Map(previous);
104
- const current = next.get(viewId);
134
+ const viewId = getStorageViewId(event.target);
135
+ setSnapshots((previous) => {
136
+ const next = new Map(previous);
137
+ const current = next.get(viewId);
105
138
 
106
- if (!current) {
107
- return previous;
108
- }
139
+ if (!current) {
140
+ return previous;
141
+ }
109
142
 
110
- const existingIndex = current.entries.findIndex(
111
- (entry) => entry.key === event.entry.key
112
- );
143
+ const existingIndex = current.entries.findIndex(
144
+ (entry) => entry.key === event.entry.key,
145
+ );
113
146
 
114
- const entries =
115
- existingIndex >= 0
116
- ? current.entries.map((entry) =>
117
- entry.key === event.entry.key ? event.entry : entry
118
- )
119
- : [...current.entries, event.entry];
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
- next.set(viewId, {
122
- ...current,
123
- entries,
154
+ next.set(viewId, {
155
+ ...current,
156
+ entries,
157
+ });
158
+
159
+ return next;
124
160
  });
125
161
 
126
- return next;
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) => [...currentEntries, entry]);
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
+ };
@@ -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
+ };