@rozenite/storage-plugin 1.9.0 → 1.11.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.
@@ -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 { useMemo, useState } from 'react';
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
- const TYPE_OPTIONS: Array<{ value: StorageEntryType; label: string }> = [
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 [newEntryType, setNewEntryType] = useState<StorageEntryType>('string');
34
- const [newEntryValue, setNewEntryValue] = useState('');
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
- type: 'confirm' | 'alert';
40
- onConfirm?: () => void;
41
- }>({ isOpen: false, title: '', message: '', type: 'alert' });
42
-
43
- const enabledTypes = useMemo(
44
- () => TYPE_OPTIONS.filter((option) => supportedTypes.includes(option.value)),
45
- [supportedTypes]
46
- );
47
-
48
- const firstSupportedType = enabledTypes[0]?.value;
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 selectedTypeSupported = supportedTypes.includes(newEntryType);
69
+ const isCurrentTypeSupported = supportedTypes.includes(currentType);
51
70
 
52
- const resetForm = () => {
71
+ const resetAndClose = () => {
53
72
  setNewEntryKey('');
54
- setNewEntryType(firstSupportedType ?? 'string');
55
- setNewEntryValue('');
73
+ setCurrentType(initialType);
74
+ setCurrentValue(defaultValueForType(initialType));
56
75
  onClose();
57
76
  };
58
77
 
59
- const handleAddEntry = () => {
60
- if (!newEntryKey.trim()) return;
78
+ const handleAdd = () => {
79
+ if (!newEntryKey.trim() || currentValue === null) return;
61
80
 
62
- if (!selectedTypeSupported) {
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
- let parsedValue: StorageEntryValue;
83
- try {
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
- resetForm();
105
+ resetAndClose();
143
106
  return;
144
107
  }
145
-
146
- if (event.key === 'Enter' && newEntryKey.trim() && newEntryValue.trim()) {
147
- handleAddEntry();
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={resetForm}
129
+ onClick={resetAndClose}
159
130
  >
160
131
  <div
161
- className="bg-gray-800 rounded-lg p-6 w-96 max-w-full mx-4"
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={resetForm}
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-type"
168
+ htmlFor="new-entry-value"
198
169
  className="block text-sm font-medium text-gray-200 mb-1"
199
170
  >
200
- Type
171
+ Value
201
172
  </label>
202
- <select
203
- id="new-entry-type"
204
- value={newEntryType}
205
- onChange={(event) => {
206
- setNewEntryType(event.target.value as StorageEntryType);
207
- setNewEntryValue('');
173
+ <TypedValueEditor
174
+ supportedTypes={supportedTypes}
175
+ type={currentType}
176
+ value={currentValue}
177
+ onChange={(nextType, nextValue) => {
178
+ setCurrentType(nextType);
179
+ setCurrentValue(nextValue);
208
180
  }}
209
- 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"
210
- >
211
- {TYPE_OPTIONS.map((option) => {
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={resetForm}
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={handleAddEntry}
288
- disabled={!newEntryKey.trim() || !newEntryValue.trim() || !selectedTypeSupported}
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={() => setConfirmDialog((previous) => ({ ...previous, isOpen: false }))}
299
- onConfirm={() => {
300
- if (confirmDialog.onConfirm) {
301
- confirmDialog.onConfirm();
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={confirmDialog.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
+ };