@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
@@ -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
+ };
@@ -0,0 +1,168 @@
1
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
2
+ import { EditorState as CMEditorState } from '@codemirror/state';
3
+ import { EditorView, keymap } from '@codemirror/view';
4
+ import { useEffect, useReducer, useRef } from 'react';
5
+ import { bytesToAsciiPreview } from './binary';
6
+ import {
7
+ initialState,
8
+ reduce,
9
+ type EditorMode,
10
+ } from './binary-value-editor-state';
11
+
12
+ export type BinaryValueEditorProps = {
13
+ initialBytes?: number[];
14
+ onChange: (bytes: number[] | null) => void;
15
+ };
16
+
17
+ const darkTheme = EditorView.theme(
18
+ {
19
+ '&': {
20
+ color: '#e5e7eb',
21
+ backgroundColor: '#111827',
22
+ fontSize: '12px',
23
+ },
24
+ '.cm-content': {
25
+ caretColor: '#60a5fa',
26
+ fontFamily:
27
+ 'ui-monospace, "Cascadia Mono", "Fira Code", Menlo, monospace',
28
+ padding: '8px',
29
+ },
30
+ '.cm-focused': { outline: 'none' },
31
+ '.cm-gutters': { display: 'none' },
32
+ '.cm-scroller': { overflow: 'auto' },
33
+ },
34
+ { dark: true },
35
+ );
36
+
37
+ const ModeButton = ({
38
+ label,
39
+ active,
40
+ onClick,
41
+ }: {
42
+ label: string;
43
+ active: boolean;
44
+ onClick: () => void;
45
+ }) => (
46
+ <button
47
+ type="button"
48
+ onClick={onClick}
49
+ className={`rounded px-2 py-1 text-xs transition-colors ${
50
+ active
51
+ ? 'bg-blue-600 text-white'
52
+ : 'bg-gray-700 text-gray-200 hover:bg-gray-600'
53
+ }`}
54
+ >
55
+ {label}
56
+ </button>
57
+ );
58
+
59
+ export const BinaryValueEditor = ({
60
+ initialBytes,
61
+ onChange,
62
+ }: BinaryValueEditorProps) => {
63
+ const [state, dispatch] = useReducer(reduce, undefined, () =>
64
+ initialState({ initialBytes }),
65
+ );
66
+
67
+ const hostRef = useRef<HTMLDivElement | null>(null);
68
+ const viewRef = useRef<EditorView | null>(null);
69
+ const stateRef = useRef(state);
70
+ stateRef.current = state;
71
+ const onChangeRef = useRef(onChange);
72
+ onChangeRef.current = onChange;
73
+
74
+ // Mount the CodeMirror view once. The update listener compares the
75
+ // doc to the latest reducer text (via stateRef) so paste-or-type
76
+ // events round-trip through the reducer instead of looping back.
77
+ useEffect(() => {
78
+ if (!hostRef.current) {
79
+ return;
80
+ }
81
+ const view = new EditorView({
82
+ state: CMEditorState.create({
83
+ doc: stateRef.current.text,
84
+ extensions: [
85
+ history(),
86
+ keymap.of([...defaultKeymap, ...historyKeymap]),
87
+ darkTheme,
88
+ EditorView.lineWrapping,
89
+ EditorView.updateListener.of((update) => {
90
+ if (!update.docChanged) return;
91
+ const newText = update.state.doc.toString();
92
+ if (newText === stateRef.current.text) return;
93
+ const isPaste = update.transactions.some((tr) =>
94
+ tr.isUserEvent('input.paste'),
95
+ );
96
+ dispatch({
97
+ type: isPaste ? 'normalize-paste' : 'set-text',
98
+ text: newText,
99
+ });
100
+ }),
101
+ ],
102
+ }),
103
+ parent: hostRef.current,
104
+ });
105
+ viewRef.current = view;
106
+ return () => {
107
+ view.destroy();
108
+ viewRef.current = null;
109
+ };
110
+ }, []);
111
+
112
+ // Push reducer text into CodeMirror only when they diverge.
113
+ // This fires for paste normalization and mode switches.
114
+ useEffect(() => {
115
+ const view = viewRef.current;
116
+ if (!view) return;
117
+ const current = view.state.doc.toString();
118
+ if (current === state.text) return;
119
+ view.dispatch({
120
+ changes: { from: 0, to: current.length, insert: state.text },
121
+ });
122
+ }, [state.text]);
123
+
124
+ useEffect(() => {
125
+ onChangeRef.current(state.bytes);
126
+ }, [state.bytes]);
127
+
128
+ const byteCount = state.bytes?.length ?? 0;
129
+ const asciiPreview = state.bytes ? bytesToAsciiPreview(state.bytes) : '';
130
+
131
+ const handleModeChange = (mode: EditorMode) => {
132
+ if (state.mode === mode) return;
133
+ dispatch({ type: 'switch-mode', mode });
134
+ };
135
+
136
+ return (
137
+ <div className="flex flex-col gap-2">
138
+ <div className="flex items-center gap-1">
139
+ <ModeButton
140
+ label="Hex"
141
+ active={state.mode === 'hex'}
142
+ onClick={() => handleModeChange('hex')}
143
+ />
144
+ <ModeButton
145
+ label="Base64"
146
+ active={state.mode === 'base64'}
147
+ onClick={() => handleModeChange('base64')}
148
+ />
149
+ </div>
150
+
151
+ <div
152
+ ref={hostRef}
153
+ className="min-h-[120px] max-h-[300px] overflow-auto rounded border border-gray-700 bg-gray-900"
154
+ />
155
+
156
+ <div className="flex flex-col gap-1 text-xs">
157
+ <div className="text-gray-400">{byteCount} bytes</div>
158
+ {asciiPreview && (
159
+ <div className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-400">
160
+ ASCII:{' '}
161
+ <span className="font-mono text-gray-300">{asciiPreview}</span>
162
+ </div>
163
+ )}
164
+ {state.error && <div className="text-red-400">{state.error}</div>}
165
+ </div>
166
+ </div>
167
+ );
168
+ };