@rozenite/storage-plugin 1.9.0 → 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 +15 -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 +38 -56
- package/dist/rozenite.json +1 -1
- package/package.json +9 -6
- package/src/react-native/adapters/__tests__/mmkv.test.ts +300 -5
- package/src/react-native/adapters/mmkv.ts +51 -40
- 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/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/dist/devtools/assets/panel-DVxtKLI9.css +0 -1
- package/dist/devtools/assets/panel-DnjbFMGo.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { convertValue, defaultValueForType } from '../type-conversion';
|
|
3
|
+
|
|
4
|
+
describe('convertValue', () => {
|
|
5
|
+
it('returns the same value when from === to', () => {
|
|
6
|
+
expect(convertValue('string', 'string', 'hello')).toBe('hello');
|
|
7
|
+
expect(convertValue('number', 'number', 42)).toBe(42);
|
|
8
|
+
expect(convertValue('boolean', 'boolean', true)).toBe(true);
|
|
9
|
+
expect(convertValue('buffer', 'buffer', [1, 2, 3])).toEqual([1, 2, 3]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('from string', () => {
|
|
13
|
+
it('parses a numeric string to a number, falls back to 0 on NaN', () => {
|
|
14
|
+
expect(convertValue('string', 'number', '42')).toBe(42);
|
|
15
|
+
expect(convertValue('string', 'number', '-3.14')).toBe(-3.14);
|
|
16
|
+
expect(convertValue('string', 'number', 'abc')).toBe(0);
|
|
17
|
+
expect(convertValue('string', 'number', '')).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses "true" / anything-else to a boolean', () => {
|
|
21
|
+
expect(convertValue('string', 'boolean', 'true')).toBe(true);
|
|
22
|
+
expect(convertValue('string', 'boolean', 'false')).toBe(false);
|
|
23
|
+
expect(convertValue('string', 'boolean', 'anything')).toBe(false);
|
|
24
|
+
expect(convertValue('string', 'boolean', '')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('UTF-8 encodes a string to a byte array', () => {
|
|
28
|
+
expect(convertValue('string', 'buffer', 'hello')).toEqual([
|
|
29
|
+
0x68, 0x65, 0x6c, 0x6c, 0x6f,
|
|
30
|
+
]);
|
|
31
|
+
// 'é' is two bytes in UTF-8: 0xC3 0xA9.
|
|
32
|
+
expect(convertValue('string', 'buffer', 'café')).toEqual([
|
|
33
|
+
0x63, 0x61, 0x66, 0xc3, 0xa9,
|
|
34
|
+
]);
|
|
35
|
+
expect(convertValue('string', 'buffer', '')).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('from number', () => {
|
|
40
|
+
it('stringifies a number when converting to string', () => {
|
|
41
|
+
expect(convertValue('number', 'string', 42)).toBe('42');
|
|
42
|
+
expect(convertValue('number', 'string', -3.14)).toBe('-3.14');
|
|
43
|
+
expect(convertValue('number', 'string', 0)).toBe('0');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('non-zero is truthy, zero is falsy for boolean conversion', () => {
|
|
47
|
+
expect(convertValue('number', 'boolean', 1)).toBe(true);
|
|
48
|
+
expect(convertValue('number', 'boolean', -1)).toBe(true);
|
|
49
|
+
expect(convertValue('number', 'boolean', 0)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('UTF-8 encodes the stringified number to buffer', () => {
|
|
53
|
+
expect(convertValue('number', 'buffer', 42)).toEqual([0x34, 0x32]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('from boolean', () => {
|
|
58
|
+
it('stringifies to "true" / "false"', () => {
|
|
59
|
+
expect(convertValue('boolean', 'string', true)).toBe('true');
|
|
60
|
+
expect(convertValue('boolean', 'string', false)).toBe('false');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('true → 1, false → 0 for number conversion', () => {
|
|
64
|
+
expect(convertValue('boolean', 'number', true)).toBe(1);
|
|
65
|
+
expect(convertValue('boolean', 'number', false)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('encodes the literal "true" / "false" bytes for buffer conversion', () => {
|
|
69
|
+
expect(convertValue('boolean', 'buffer', true)).toEqual([
|
|
70
|
+
0x74, 0x72, 0x75, 0x65,
|
|
71
|
+
]);
|
|
72
|
+
expect(convertValue('boolean', 'buffer', false)).toEqual([
|
|
73
|
+
0x66, 0x61, 0x6c, 0x73, 0x65,
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('from buffer', () => {
|
|
79
|
+
it('UTF-8 decodes bytes back to a string (the headline round-trip)', () => {
|
|
80
|
+
expect(
|
|
81
|
+
convertValue('buffer', 'string', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
|
|
82
|
+
).toBe('hello');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns an empty string when bytes are not valid UTF-8', () => {
|
|
86
|
+
expect(convertValue('buffer', 'string', [0xff, 0xfe, 0xfd])).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('decodes then parses to number, falls back to 0 on failure', () => {
|
|
90
|
+
expect(convertValue('buffer', 'number', [0x34, 0x32])).toBe(42); // "42"
|
|
91
|
+
expect(
|
|
92
|
+
convertValue('buffer', 'number', [0x68, 0x65, 0x6c, 0x6c, 0x6f]),
|
|
93
|
+
).toBe(0); // "hello" → NaN → 0
|
|
94
|
+
expect(convertValue('buffer', 'number', [0xff])).toBe(0); // invalid UTF-8 → 0
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('decodes then matches "true" for boolean conversion', () => {
|
|
98
|
+
expect(convertValue('buffer', 'boolean', [0x74, 0x72, 0x75, 0x65])).toBe(
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
101
|
+
expect(
|
|
102
|
+
convertValue('buffer', 'boolean', [0x66, 0x61, 0x6c, 0x73, 0x65]),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
expect(convertValue('buffer', 'boolean', [0xff])).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('round-trips string ↔ buffer faithfully', () => {
|
|
109
|
+
const original = 'Hello, world! — 你好';
|
|
110
|
+
const bytes = convertValue('string', 'buffer', original) as number[];
|
|
111
|
+
const back = convertValue('buffer', 'string', bytes);
|
|
112
|
+
expect(back).toBe(original);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('defaultValueForType', () => {
|
|
117
|
+
it('returns sensible zero values', () => {
|
|
118
|
+
expect(defaultValueForType('string')).toBe('');
|
|
119
|
+
expect(defaultValueForType('number')).toBe(0);
|
|
120
|
+
expect(defaultValueForType('boolean')).toBe(false);
|
|
121
|
+
expect(defaultValueForType('buffer')).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { X } from 'lucide-react';
|
|
3
3
|
import type {
|
|
4
4
|
StorageEntry,
|
|
@@ -6,13 +6,8 @@ import type {
|
|
|
6
6
|
StorageEntryValue,
|
|
7
7
|
} from '../shared/types';
|
|
8
8
|
import { ConfirmDialog } from './confirm-dialog';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{ value: 'string', label: 'String' },
|
|
12
|
-
{ value: 'number', label: 'Number' },
|
|
13
|
-
{ value: 'boolean', label: 'Boolean' },
|
|
14
|
-
{ value: 'buffer', label: 'Buffer (Array)' },
|
|
15
|
-
];
|
|
9
|
+
import { TypedValueEditor } from './typed-value-editor';
|
|
10
|
+
import { defaultValueForType } from './type-conversion';
|
|
16
11
|
|
|
17
12
|
export type AddEntryDialogProps = {
|
|
18
13
|
isOpen: boolean;
|
|
@@ -22,6 +17,23 @@ export type AddEntryDialogProps = {
|
|
|
22
17
|
supportedTypes: StorageEntryType[];
|
|
23
18
|
};
|
|
24
19
|
|
|
20
|
+
const buildEntry = (
|
|
21
|
+
key: string,
|
|
22
|
+
type: StorageEntryType,
|
|
23
|
+
value: StorageEntryValue,
|
|
24
|
+
): StorageEntry => {
|
|
25
|
+
switch (type) {
|
|
26
|
+
case 'string':
|
|
27
|
+
return { key, type: 'string', value: value as string };
|
|
28
|
+
case 'number':
|
|
29
|
+
return { key, type: 'number', value: value as number };
|
|
30
|
+
case 'boolean':
|
|
31
|
+
return { key, type: 'boolean', value: value as boolean };
|
|
32
|
+
case 'buffer':
|
|
33
|
+
return { key, type: 'buffer', value: value as number[] };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
25
37
|
export const AddEntryDialog = ({
|
|
26
38
|
isOpen,
|
|
27
39
|
onClose,
|
|
@@ -29,42 +41,48 @@ export const AddEntryDialog = ({
|
|
|
29
41
|
existingKeys,
|
|
30
42
|
supportedTypes,
|
|
31
43
|
}: AddEntryDialogProps) => {
|
|
44
|
+
const initialType: StorageEntryType = supportedTypes.includes('string')
|
|
45
|
+
? 'string'
|
|
46
|
+
: (supportedTypes[0] ?? 'string');
|
|
47
|
+
|
|
32
48
|
const [newEntryKey, setNewEntryKey] = useState('');
|
|
33
|
-
const [
|
|
34
|
-
const [
|
|
49
|
+
const [currentType, setCurrentType] = useState<StorageEntryType>(initialType);
|
|
50
|
+
const [currentValue, setCurrentValue] = useState<StorageEntryValue | null>(
|
|
51
|
+
defaultValueForType(initialType),
|
|
52
|
+
);
|
|
35
53
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
36
54
|
isOpen: boolean;
|
|
37
55
|
title: string;
|
|
38
56
|
message: string;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
}>({ isOpen: false, title: '', message: '' });
|
|
58
|
+
|
|
59
|
+
// Reset state every time the dialog opens, so a previous session's
|
|
60
|
+
// type / value doesn't bleed in.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (isOpen) {
|
|
63
|
+
setNewEntryKey('');
|
|
64
|
+
setCurrentType(initialType);
|
|
65
|
+
setCurrentValue(defaultValueForType(initialType));
|
|
66
|
+
}
|
|
67
|
+
}, [isOpen, initialType]);
|
|
49
68
|
|
|
50
|
-
const
|
|
69
|
+
const isCurrentTypeSupported = supportedTypes.includes(currentType);
|
|
51
70
|
|
|
52
|
-
const
|
|
71
|
+
const resetAndClose = () => {
|
|
53
72
|
setNewEntryKey('');
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
setCurrentType(initialType);
|
|
74
|
+
setCurrentValue(defaultValueForType(initialType));
|
|
56
75
|
onClose();
|
|
57
76
|
};
|
|
58
77
|
|
|
59
|
-
const
|
|
60
|
-
if (!newEntryKey.trim()) return;
|
|
78
|
+
const handleAdd = () => {
|
|
79
|
+
if (!newEntryKey.trim() || currentValue === null) return;
|
|
61
80
|
|
|
62
|
-
if (!
|
|
81
|
+
if (!isCurrentTypeSupported) {
|
|
63
82
|
setConfirmDialog({
|
|
64
83
|
isOpen: true,
|
|
65
84
|
title: 'Unsupported Type',
|
|
66
85
|
message: 'Selected type is not supported by this storage.',
|
|
67
|
-
type: 'alert',
|
|
68
86
|
});
|
|
69
87
|
return;
|
|
70
88
|
}
|
|
@@ -74,77 +92,25 @@ export const AddEntryDialog = ({
|
|
|
74
92
|
isOpen: true,
|
|
75
93
|
title: 'Key Already Exists',
|
|
76
94
|
message: 'An entry with this key already exists.',
|
|
77
|
-
type: 'alert',
|
|
78
95
|
});
|
|
79
96
|
return;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
switch (newEntryType) {
|
|
85
|
-
case 'string':
|
|
86
|
-
parsedValue = newEntryValue;
|
|
87
|
-
break;
|
|
88
|
-
case 'number':
|
|
89
|
-
parsedValue = Number(newEntryValue);
|
|
90
|
-
if (Number.isNaN(parsedValue)) {
|
|
91
|
-
throw new Error('Invalid number');
|
|
92
|
-
}
|
|
93
|
-
break;
|
|
94
|
-
case 'boolean':
|
|
95
|
-
if (newEntryValue !== 'true' && newEntryValue !== 'false') {
|
|
96
|
-
throw new Error('Boolean value must be true or false');
|
|
97
|
-
}
|
|
98
|
-
parsedValue = newEntryValue === 'true';
|
|
99
|
-
break;
|
|
100
|
-
case 'buffer':
|
|
101
|
-
parsedValue = JSON.parse(newEntryValue);
|
|
102
|
-
if (
|
|
103
|
-
!Array.isArray(parsedValue) ||
|
|
104
|
-
!parsedValue.every((value) => typeof value === 'number')
|
|
105
|
-
) {
|
|
106
|
-
throw new Error('Buffer must be an array of numbers');
|
|
107
|
-
}
|
|
108
|
-
break;
|
|
109
|
-
default:
|
|
110
|
-
throw new Error('Invalid type');
|
|
111
|
-
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
setConfirmDialog({
|
|
114
|
-
isOpen: true,
|
|
115
|
-
title: 'Invalid Value',
|
|
116
|
-
message: `Invalid value for ${newEntryType}: ${
|
|
117
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
118
|
-
}`,
|
|
119
|
-
type: 'alert',
|
|
120
|
-
});
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
let entry: StorageEntry;
|
|
125
|
-
if (newEntryType === 'string') {
|
|
126
|
-
entry = { key: newEntryKey, type: 'string', value: parsedValue as string };
|
|
127
|
-
} else if (newEntryType === 'number') {
|
|
128
|
-
entry = { key: newEntryKey, type: 'number', value: parsedValue as number };
|
|
129
|
-
} else if (newEntryType === 'boolean') {
|
|
130
|
-
entry = { key: newEntryKey, type: 'boolean', value: parsedValue as boolean };
|
|
131
|
-
} else {
|
|
132
|
-
entry = { key: newEntryKey, type: 'buffer', value: parsedValue as number[] };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
onAddEntry(entry);
|
|
136
|
-
|
|
137
|
-
resetForm();
|
|
99
|
+
onAddEntry(buildEntry(newEntryKey, currentType, currentValue));
|
|
100
|
+
resetAndClose();
|
|
138
101
|
};
|
|
139
102
|
|
|
140
103
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
141
104
|
if (event.key === 'Escape') {
|
|
142
|
-
|
|
105
|
+
resetAndClose();
|
|
143
106
|
return;
|
|
144
107
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
108
|
+
if (
|
|
109
|
+
event.key === 'Enter' &&
|
|
110
|
+
newEntryKey.trim() &&
|
|
111
|
+
currentType !== 'buffer'
|
|
112
|
+
) {
|
|
113
|
+
handleAdd();
|
|
148
114
|
}
|
|
149
115
|
};
|
|
150
116
|
|
|
@@ -152,20 +118,25 @@ export const AddEntryDialog = ({
|
|
|
152
118
|
return null;
|
|
153
119
|
}
|
|
154
120
|
|
|
121
|
+
// Unsavable when no key, no supported type, or when the value is
|
|
122
|
+
// null — the hex editor signals invalid / empty hex via null.
|
|
123
|
+
const isAddDisabled =
|
|
124
|
+
!newEntryKey.trim() || !isCurrentTypeSupported || currentValue === null;
|
|
125
|
+
|
|
155
126
|
return (
|
|
156
127
|
<div
|
|
157
128
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
158
|
-
onClick={
|
|
129
|
+
onClick={resetAndClose}
|
|
159
130
|
>
|
|
160
131
|
<div
|
|
161
|
-
className="bg-gray-800 rounded-lg p-6 w-
|
|
132
|
+
className="bg-gray-800 rounded-lg p-6 w-[32rem] max-w-full mx-4"
|
|
162
133
|
onClick={(event) => event.stopPropagation()}
|
|
163
134
|
onKeyDown={handleKeyDown}
|
|
164
135
|
>
|
|
165
136
|
<div className="flex items-center justify-between mb-4">
|
|
166
137
|
<h2 className="text-lg font-semibold text-gray-100">Add New Entry</h2>
|
|
167
138
|
<button
|
|
168
|
-
onClick={
|
|
139
|
+
onClick={resetAndClose}
|
|
169
140
|
className="p-1 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
|
|
170
141
|
title="Close dialog"
|
|
171
142
|
>
|
|
@@ -194,98 +165,39 @@ export const AddEntryDialog = ({
|
|
|
194
165
|
|
|
195
166
|
<div>
|
|
196
167
|
<label
|
|
197
|
-
htmlFor="new-entry-
|
|
168
|
+
htmlFor="new-entry-value"
|
|
198
169
|
className="block text-sm font-medium text-gray-200 mb-1"
|
|
199
170
|
>
|
|
200
|
-
|
|
171
|
+
Value
|
|
201
172
|
</label>
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
173
|
+
<TypedValueEditor
|
|
174
|
+
supportedTypes={supportedTypes}
|
|
175
|
+
type={currentType}
|
|
176
|
+
value={currentValue}
|
|
177
|
+
onChange={(nextType, nextValue) => {
|
|
178
|
+
setCurrentType(nextType);
|
|
179
|
+
setCurrentValue(nextValue);
|
|
208
180
|
}}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const isSupported = supportedTypes.includes(option.value);
|
|
213
|
-
return (
|
|
214
|
-
<option
|
|
215
|
-
key={option.value}
|
|
216
|
-
value={option.value}
|
|
217
|
-
disabled={!isSupported}
|
|
218
|
-
title={
|
|
219
|
-
isSupported ? option.label : 'Not supported by this storage'
|
|
220
|
-
}
|
|
221
|
-
>
|
|
222
|
-
{option.label}
|
|
223
|
-
{!isSupported ? ' (Not supported)' : ''}
|
|
224
|
-
</option>
|
|
225
|
-
);
|
|
226
|
-
})}
|
|
227
|
-
</select>
|
|
228
|
-
{!selectedTypeSupported && (
|
|
181
|
+
inputId="new-entry-value"
|
|
182
|
+
/>
|
|
183
|
+
{!isCurrentTypeSupported && (
|
|
229
184
|
<p className="text-xs text-amber-400 mt-1">
|
|
230
185
|
Selected type is not supported by this storage.
|
|
231
186
|
</p>
|
|
232
187
|
)}
|
|
233
188
|
</div>
|
|
234
|
-
|
|
235
|
-
<div>
|
|
236
|
-
<label
|
|
237
|
-
htmlFor="new-entry-value"
|
|
238
|
-
className="block text-sm font-medium text-gray-200 mb-1"
|
|
239
|
-
>
|
|
240
|
-
Value
|
|
241
|
-
</label>
|
|
242
|
-
{newEntryType === 'boolean' ? (
|
|
243
|
-
<select
|
|
244
|
-
id="new-entry-value"
|
|
245
|
-
value={newEntryValue}
|
|
246
|
-
onChange={(event) => setNewEntryValue(event.target.value)}
|
|
247
|
-
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"
|
|
248
|
-
>
|
|
249
|
-
<option value="">Select value</option>
|
|
250
|
-
<option value="true">true</option>
|
|
251
|
-
<option value="false">false</option>
|
|
252
|
-
</select>
|
|
253
|
-
) : (
|
|
254
|
-
<input
|
|
255
|
-
id="new-entry-value"
|
|
256
|
-
type={newEntryType === 'number' ? 'number' : 'text'}
|
|
257
|
-
value={newEntryValue}
|
|
258
|
-
onChange={(event) => setNewEntryValue(event.target.value)}
|
|
259
|
-
placeholder={
|
|
260
|
-
newEntryType === 'string'
|
|
261
|
-
? 'Enter string value'
|
|
262
|
-
: newEntryType === 'number'
|
|
263
|
-
? 'Enter number value'
|
|
264
|
-
: newEntryType === 'buffer'
|
|
265
|
-
? 'Enter array as JSON, e.g., [1, 2, 3]'
|
|
266
|
-
: 'Enter value'
|
|
267
|
-
}
|
|
268
|
-
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"
|
|
269
|
-
/>
|
|
270
|
-
)}
|
|
271
|
-
{newEntryType === 'buffer' && (
|
|
272
|
-
<p className="text-xs text-gray-400 mt-1">
|
|
273
|
-
Enter as JSON array of numbers, e.g., [1, 2, 3, 255]
|
|
274
|
-
</p>
|
|
275
|
-
)}
|
|
276
|
-
</div>
|
|
277
189
|
</div>
|
|
278
190
|
|
|
279
191
|
<div className="flex items-center justify-end gap-2 mt-6">
|
|
280
192
|
<button
|
|
281
|
-
onClick={
|
|
193
|
+
onClick={resetAndClose}
|
|
282
194
|
className="px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
283
195
|
>
|
|
284
196
|
Cancel
|
|
285
197
|
</button>
|
|
286
198
|
<button
|
|
287
|
-
onClick={
|
|
288
|
-
disabled={
|
|
199
|
+
onClick={handleAdd}
|
|
200
|
+
disabled={isAddDisabled}
|
|
289
201
|
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
|
290
202
|
>
|
|
291
203
|
Add Entry
|
|
@@ -295,15 +207,13 @@ export const AddEntryDialog = ({
|
|
|
295
207
|
|
|
296
208
|
<ConfirmDialog
|
|
297
209
|
isOpen={confirmDialog.isOpen}
|
|
298
|
-
onClose={() =>
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
}}
|
|
210
|
+
onClose={() =>
|
|
211
|
+
setConfirmDialog((previous) => ({ ...previous, isOpen: false }))
|
|
212
|
+
}
|
|
213
|
+
onConfirm={() => {}}
|
|
304
214
|
title={confirmDialog.title}
|
|
305
215
|
message={confirmDialog.message}
|
|
306
|
-
type=
|
|
216
|
+
type="alert"
|
|
307
217
|
/>
|
|
308
218
|
</div>
|
|
309
219
|
);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
base64ToBytes,
|
|
3
|
+
bytesToBase64,
|
|
4
|
+
bytesToGroupedHex,
|
|
5
|
+
hexInputToBytes,
|
|
6
|
+
} from './binary';
|
|
7
|
+
|
|
8
|
+
export type EditorMode = 'hex' | 'base64';
|
|
9
|
+
|
|
10
|
+
export type EditorState = {
|
|
11
|
+
mode: EditorMode;
|
|
12
|
+
text: string;
|
|
13
|
+
bytes: number[] | null;
|
|
14
|
+
error: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type EditorAction =
|
|
18
|
+
| { type: 'set-text'; text: string }
|
|
19
|
+
| { type: 'normalize-paste'; text: string }
|
|
20
|
+
| { type: 'switch-mode'; mode: EditorMode };
|
|
21
|
+
|
|
22
|
+
export type Validation =
|
|
23
|
+
| { ok: true; bytes: number[] }
|
|
24
|
+
| { ok: false; reason: string };
|
|
25
|
+
|
|
26
|
+
const encode = (bytes: readonly number[], mode: EditorMode): string =>
|
|
27
|
+
mode === 'hex' ? bytesToGroupedHex(bytes) : bytesToBase64(bytes);
|
|
28
|
+
|
|
29
|
+
const parse = (text: string, mode: EditorMode) =>
|
|
30
|
+
mode === 'hex' ? hexInputToBytes(text) : base64ToBytes(text);
|
|
31
|
+
|
|
32
|
+
const parsedToState = (
|
|
33
|
+
text: string,
|
|
34
|
+
mode: EditorMode,
|
|
35
|
+
): Pick<EditorState, 'bytes' | 'error'> => {
|
|
36
|
+
const result = parse(text, mode);
|
|
37
|
+
return result.ok
|
|
38
|
+
? { bytes: result.value, error: null }
|
|
39
|
+
: { bytes: null, error: result.error };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const initialState = (args: {
|
|
43
|
+
initialBytes?: number[];
|
|
44
|
+
mode?: EditorMode;
|
|
45
|
+
}): EditorState => {
|
|
46
|
+
const mode = args.mode ?? 'hex';
|
|
47
|
+
if (args.initialBytes && args.initialBytes.length > 0) {
|
|
48
|
+
return {
|
|
49
|
+
mode,
|
|
50
|
+
text: encode(args.initialBytes, mode),
|
|
51
|
+
bytes: args.initialBytes,
|
|
52
|
+
error: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
mode,
|
|
57
|
+
text: '',
|
|
58
|
+
bytes: null,
|
|
59
|
+
error: null,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const reduce = (
|
|
64
|
+
state: EditorState,
|
|
65
|
+
action: EditorAction,
|
|
66
|
+
): EditorState => {
|
|
67
|
+
switch (action.type) {
|
|
68
|
+
case 'set-text': {
|
|
69
|
+
return {
|
|
70
|
+
...state,
|
|
71
|
+
text: action.text,
|
|
72
|
+
...parsedToState(action.text, state.mode),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
case 'normalize-paste': {
|
|
76
|
+
const result = parse(action.text, state.mode);
|
|
77
|
+
if (result.ok) {
|
|
78
|
+
return {
|
|
79
|
+
...state,
|
|
80
|
+
text: encode(result.value, state.mode),
|
|
81
|
+
bytes: result.value,
|
|
82
|
+
error: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
text: action.text,
|
|
88
|
+
bytes: null,
|
|
89
|
+
error: result.error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
case 'switch-mode': {
|
|
93
|
+
if (state.mode === action.mode) {
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
if (state.bytes !== null) {
|
|
97
|
+
return {
|
|
98
|
+
mode: action.mode,
|
|
99
|
+
text: encode(state.bytes, action.mode),
|
|
100
|
+
bytes: state.bytes,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
mode: action.mode,
|
|
106
|
+
text: '',
|
|
107
|
+
bytes: null,
|
|
108
|
+
error: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const validate = (state: EditorState): Validation => {
|
|
115
|
+
if (state.bytes === null) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: state.error ?? 'Enter at least one byte.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (state.bytes.length === 0) {
|
|
122
|
+
return { ok: false, reason: 'Enter at least one byte.' };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, bytes: state.bytes };
|
|
125
|
+
};
|