@rozenite/storage-plugin 1.13.0 → 2.0.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 (79) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +6 -1
  3. package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
  4. package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/async-storage.require.cjs +1 -1
  7. package/dist/react-native/chunks/async-storage.require.js +8 -7
  8. package/dist/react-native/chunks/index.require.cjs +1 -1
  9. package/dist/react-native/chunks/index.require.js +70 -64
  10. package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
  11. package/dist/react-native/chunks/secure-storage.require.js +22 -14
  12. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  13. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
  14. package/dist/react-native/index.d.ts +154 -18
  15. package/dist/react-native/index.js +10 -2
  16. package/dist/rozenite.json +1 -1
  17. package/dist/sdk/index.cjs +1 -1
  18. package/dist/sdk/index.d.ts +13 -5
  19. package/dist/sdk/index.js +21 -12
  20. package/package.json +13 -15
  21. package/react-native.ts +29 -14
  22. package/rozenite.config.ts +78 -0
  23. package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
  24. package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
  25. package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
  26. package/src/react-native/__tests__/import.test.ts +172 -10
  27. package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
  28. package/src/react-native/__tests__/storage-view.test.ts +172 -0
  29. package/src/react-native/__tests__/stress-verification.test.ts +153 -0
  30. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
  31. package/src/react-native/adapters/async-storage.ts +9 -7
  32. package/src/react-native/adapters/mmkv.ts +5 -0
  33. package/src/react-native/adapters/secure-storage.ts +9 -1
  34. package/src/react-native/entry-preview-pagination.ts +277 -0
  35. package/src/react-native/export-snapshot.ts +94 -0
  36. package/src/react-native/full-entry-request.ts +96 -0
  37. package/src/react-native/import.ts +190 -16
  38. package/src/react-native/storage-discovery.ts +96 -0
  39. package/src/react-native/storage-view.ts +45 -103
  40. package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
  41. package/src/react-native/useStorageAgentTools.ts +207 -86
  42. package/src/shared/__tests__/entry-preview.test.ts +117 -0
  43. package/src/shared/__tests__/snapshot.test.ts +21 -0
  44. package/src/shared/agent-tools.ts +27 -11
  45. package/src/shared/entry-preview.ts +59 -0
  46. package/src/shared/messaging.ts +136 -21
  47. package/src/shared/snapshot.ts +8 -1
  48. package/src/shared/types.ts +20 -1
  49. package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
  50. package/src/ui/__tests__/binary.test.ts +18 -0
  51. package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
  52. package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
  53. package/src/ui/__tests__/panel.test.tsx +409 -0
  54. package/src/ui/__tests__/storage-groups.test.ts +91 -0
  55. package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
  56. package/src/ui/add-entry-dialog.tsx +65 -105
  57. package/src/ui/binary-value-editor-state.ts +10 -1
  58. package/src/ui/binary-value-editor.tsx +57 -33
  59. package/src/ui/binary.ts +56 -4
  60. package/src/ui/edit-entry-dialog.tsx +71 -99
  61. package/src/ui/editor-switcher.tsx +7 -5
  62. package/src/ui/entry-detail-dialog.tsx +69 -179
  63. package/src/ui/format-value.tsx +32 -0
  64. package/src/ui/globals.css +1 -123
  65. package/src/ui/import-dialog.tsx +66 -91
  66. package/src/ui/large-value-viewer-state.ts +32 -0
  67. package/src/ui/large-value-viewer.tsx +71 -0
  68. package/src/ui/panel.tsx +724 -496
  69. package/src/ui/query-client.tsx +34 -0
  70. package/src/ui/storage-groups.ts +66 -0
  71. package/src/ui/typed-value-editor.tsx +14 -15
  72. package/src/ui/use-storage-entries.ts +207 -0
  73. package/tsconfig.json +5 -1
  74. package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
  75. package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
  76. package/postcss.config.js +0 -6
  77. package/src/ui/confirm-dialog.tsx +0 -100
  78. package/src/ui/editable-table.tsx +0 -300
  79. package/tailwind.config.ts +0 -94
@@ -0,0 +1,353 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { QueryClientProvider } from '@tanstack/react-query';
4
+ import { act } from 'react';
5
+ import { createRoot } from 'react-dom/client';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import type { RozeniteDevToolsRequestClient } from '@rozenite/plugin-bridge';
8
+ import type { StorageEventMap } from '../../shared/messaging';
9
+ import type { StorageTarget } from '../../shared/types';
10
+ import { createStorageQueryClient } from '../query-client';
11
+ import {
12
+ resetSelectedStorageQueries,
13
+ dropStorageFullEntries,
14
+ invalidateStorageEntryPreviews,
15
+ storageEntryPreviewQueryKey,
16
+ useStorageEntryPreviews,
17
+ useStorageFullEntry,
18
+ } from '../use-storage-entries';
19
+
20
+ declare global {
21
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
22
+ }
23
+
24
+ const target: StorageTarget = { adapterId: 'mmkv', storageId: 'default' };
25
+ const otherTarget: StorageTarget = { adapterId: 'async', storageId: 'other' };
26
+
27
+ const response = (
28
+ cursor: string | undefined,
29
+ options: { next?: string; previous?: string } = {},
30
+ ) => ({
31
+ type: 'entry-previews' as const,
32
+ requestId: `request-${cursor ?? 'first'}`,
33
+ target,
34
+ items: [
35
+ {
36
+ key: cursor ?? 'first',
37
+ type: 'string' as const,
38
+ preview: cursor ?? 'first',
39
+ valueSize: (cursor ?? 'first').length,
40
+ isTruncated: false,
41
+ },
42
+ ],
43
+ nextCursor: options.next,
44
+ previousCursor: options.previous,
45
+ });
46
+
47
+ type PreviewProps = Parameters<typeof useStorageEntryPreviews>[0];
48
+
49
+ const renderPreviewHook = async (initialProps: PreviewProps) => {
50
+ const queryClient = createStorageQueryClient();
51
+ let result: ReturnType<typeof useStorageEntryPreviews> | undefined;
52
+ const container = document.createElement('div');
53
+ const root = createRoot(container);
54
+
55
+ const Hook = (props: PreviewProps) => {
56
+ result = useStorageEntryPreviews(props);
57
+ return null;
58
+ };
59
+
60
+ const render = async (props: PreviewProps) => {
61
+ await act(async () => {
62
+ root.render(
63
+ <QueryClientProvider client={queryClient}>
64
+ <Hook {...props} />
65
+ </QueryClientProvider>,
66
+ );
67
+ });
68
+ };
69
+
70
+ await render(initialProps);
71
+ return {
72
+ get result() {
73
+ if (!result) throw new Error('Hook did not render.');
74
+ return result;
75
+ },
76
+ queryClient,
77
+ rerender: render,
78
+ unmount: async () => {
79
+ await act(async () => root.unmount());
80
+ container.remove();
81
+ },
82
+ };
83
+ };
84
+
85
+ const asClient = (request: ReturnType<typeof vi.fn>) =>
86
+ ({ request }) as unknown as RozeniteDevToolsRequestClient<StorageEventMap>;
87
+
88
+ describe('storage entry query hooks', () => {
89
+ afterEach(() => {
90
+ document.body.innerHTML = '';
91
+ delete globalThis.IS_REACT_ACT_ENVIRONMENT;
92
+ });
93
+
94
+ it('loads both directions and bounds retained pages', async () => {
95
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
96
+ const request = vi.fn(({ payload }) => {
97
+ if (payload.cursor === undefined)
98
+ return Promise.resolve(response(undefined, { next: 'next' }));
99
+ if (payload.cursor === 'next')
100
+ return Promise.resolve(
101
+ response('next', { next: 'last', previous: 'first' }),
102
+ );
103
+ if (payload.cursor === 'last')
104
+ return Promise.resolve(response('last', { previous: 'next' }));
105
+ return Promise.resolve(response('first', { next: 'next' }));
106
+ });
107
+ const hook = await renderPreviewHook({
108
+ client: asClient(request),
109
+ target,
110
+ search: '',
111
+ keySortDirection: 'ascending',
112
+ maxPages: 2,
113
+ });
114
+
115
+ await act(async () => {
116
+ await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
117
+ });
118
+ await act(async () => hook.result.fetchNextPage());
119
+ await act(async () => hook.result.fetchNextPage());
120
+ expect(
121
+ request.mock.calls.map(([options]) => options.payload.cursor),
122
+ ).toEqual([undefined, 'next', 'last']);
123
+ const queryKey = storageEntryPreviewQueryKey(target, '', 'ascending');
124
+ const pageKeys = () =>
125
+ hook.queryClient
126
+ .getQueryData<{ pages: Array<ReturnType<typeof response>> }>(queryKey)
127
+ ?.pages.map((page) => page.items[0].key);
128
+ await vi.waitFor(() => expect(pageKeys()).toEqual(['next', 'last']));
129
+
130
+ await act(async () => hook.result.fetchPreviousPage());
131
+ await vi.waitFor(() => expect(pageKeys()).toEqual(['first', 'next']));
132
+ await hook.unmount();
133
+ });
134
+
135
+ it('cancels stale target requests and starts a new sequence at the first page', async () => {
136
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
137
+ let resolveFirst:
138
+ | ((value: ReturnType<typeof response>) => void)
139
+ | undefined;
140
+ const request = vi.fn(({ payload }) => {
141
+ if (payload.target === target) {
142
+ return new Promise((resolve) => {
143
+ resolveFirst = resolve;
144
+ });
145
+ }
146
+ return Promise.resolve({ ...response(undefined), target: otherTarget });
147
+ });
148
+ const hook = await renderPreviewHook({
149
+ client: asClient(request),
150
+ target,
151
+ search: 'one',
152
+ keySortDirection: 'ascending',
153
+ });
154
+ const firstSignal = request.mock.calls[0][0].signal as AbortSignal;
155
+
156
+ await hook.rerender({
157
+ client: asClient(request),
158
+ target: otherTarget,
159
+ search: 'two',
160
+ keySortDirection: 'descending',
161
+ });
162
+ expect(firstSignal.aborted).toBe(true);
163
+ expect(request.mock.calls[1][0].payload).toMatchObject({
164
+ cursor: undefined,
165
+ search: 'two',
166
+ keySortDirection: 'descending',
167
+ });
168
+
169
+ await act(async () => resolveFirst?.(response(undefined)));
170
+ await vi.waitFor(() =>
171
+ expect(
172
+ hook.queryClient.getQueryData<{
173
+ pages: Array<ReturnType<typeof response>>;
174
+ }>(storageEntryPreviewQueryKey(otherTarget, 'two', 'descending'))
175
+ ?.pages[0].target,
176
+ ).toEqual(otherTarget),
177
+ );
178
+ await hook.unmount();
179
+ });
180
+
181
+ it('keeps the previous pages while a search or sort change is loading', async () => {
182
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
183
+ let resolveNext: ((value: ReturnType<typeof response>) => void) | undefined;
184
+ const request = vi.fn(({ payload }) => {
185
+ if (payload.search === 'next') {
186
+ return new Promise((resolve) => {
187
+ resolveNext = resolve;
188
+ });
189
+ }
190
+ return Promise.resolve(response(undefined));
191
+ });
192
+ const hook = await renderPreviewHook({
193
+ client: asClient(request),
194
+ target,
195
+ search: 'current',
196
+ keySortDirection: 'ascending',
197
+ });
198
+
199
+ await act(async () => {
200
+ await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
201
+ });
202
+ expect(hook.result.data?.pages[0].items[0].key).toBe('first');
203
+
204
+ await hook.rerender({
205
+ client: asClient(request),
206
+ target,
207
+ search: 'next',
208
+ keySortDirection: 'descending',
209
+ });
210
+
211
+ expect(hook.result.isFetching).toBe(true);
212
+ expect(hook.result.isPlaceholderData).toBe(true);
213
+ expect(hook.result.data?.pages[0].items[0].key).toBe('first');
214
+ expect(request.mock.calls[1][0].payload).toMatchObject({
215
+ search: 'next',
216
+ keySortDirection: 'descending',
217
+ });
218
+
219
+ await act(async () => {
220
+ resolveNext?.(response('new'));
221
+ await vi.waitFor(() => expect(hook.result.isPlaceholderData).toBe(false));
222
+ });
223
+ expect(hook.result.data?.pages[0].items[0].key).toBe('new');
224
+ await hook.unmount();
225
+ });
226
+
227
+ it('exposes request errors without retrying bridge work', async () => {
228
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
229
+ const request = vi.fn(() => Promise.reject(new Error('device failed')));
230
+ const hook = await renderPreviewHook({
231
+ client: asClient(request),
232
+ target,
233
+ search: '',
234
+ keySortDirection: 'ascending',
235
+ });
236
+
237
+ await act(async () => {
238
+ await vi.waitFor(() => expect(hook.result.isError).toBe(true));
239
+ });
240
+ expect(request).toHaveBeenCalledTimes(1);
241
+ await hook.unmount();
242
+ });
243
+
244
+ it('resets every selected-storage preview variant but refetches only the active first page', async () => {
245
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
246
+ const request = vi.fn(({ payload }) =>
247
+ Promise.resolve(response(payload.cursor)),
248
+ );
249
+ const hook = await renderPreviewHook({
250
+ client: asClient(request),
251
+ target,
252
+ search: 'active',
253
+ keySortDirection: 'ascending',
254
+ });
255
+ await act(async () => {
256
+ await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
257
+ });
258
+ hook.queryClient.setQueryData(
259
+ storageEntryPreviewQueryKey(target, 'inactive', 'ascending'),
260
+ { pages: [response(undefined)], pageParams: [undefined] },
261
+ );
262
+
263
+ await act(async () =>
264
+ resetSelectedStorageQueries(hook.queryClient, target),
265
+ );
266
+ expect(request).toHaveBeenCalledTimes(2);
267
+ expect(
268
+ hook.queryClient.getQueryData(
269
+ storageEntryPreviewQueryKey(target, 'inactive', 'ascending'),
270
+ ),
271
+ ).toBeUndefined();
272
+ await hook.unmount();
273
+ });
274
+
275
+ it('invalidates only the changed storage preview prefix and drops its stale full value', async () => {
276
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
277
+ const request = vi.fn(({ payload }) =>
278
+ Promise.resolve(response(payload.cursor)),
279
+ );
280
+ const hook = await renderPreviewHook({
281
+ client: asClient(request),
282
+ target,
283
+ search: 'active',
284
+ keySortDirection: 'ascending',
285
+ });
286
+ await act(async () => {
287
+ await vi.waitFor(() => expect(hook.result.isSuccess).toBe(true));
288
+ });
289
+ const otherQueryKey = storageEntryPreviewQueryKey(
290
+ otherTarget,
291
+ 'other',
292
+ 'ascending',
293
+ );
294
+ hook.queryClient.setQueryDefaults(otherQueryKey, { gcTime: Infinity });
295
+ hook.queryClient.setQueryData(otherQueryKey, {
296
+ pages: [response(undefined)],
297
+ pageParams: [undefined],
298
+ });
299
+ hook.queryClient.setQueryData(
300
+ ['storage-full-entry', target.adapterId, target.storageId, 'changed'],
301
+ { entry: { key: 'changed', type: 'string', value: 'stale full value' } },
302
+ );
303
+
304
+ await act(async () => {
305
+ await invalidateStorageEntryPreviews(hook.queryClient, target);
306
+ await dropStorageFullEntries(hook.queryClient, target, 'changed');
307
+ });
308
+
309
+ expect(request).toHaveBeenCalledTimes(2);
310
+ expect(hook.queryClient.getQueryData(otherQueryKey)).toBeDefined();
311
+ expect(
312
+ hook.queryClient.getQueryData([
313
+ 'storage-full-entry',
314
+ target.adapterId,
315
+ target.storageId,
316
+ 'changed',
317
+ ]),
318
+ ).toBeUndefined();
319
+ await hook.unmount();
320
+ });
321
+
322
+ it('removes a full value when its interaction unmounts', async () => {
323
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
324
+ const queryClient = createStorageQueryClient();
325
+ const request = vi.fn(() =>
326
+ Promise.resolve({
327
+ type: 'entry' as const,
328
+ requestId: 'entry-1',
329
+ target,
330
+ entry: { key: 'large', type: 'string' as const, value: 'full value' },
331
+ }),
332
+ );
333
+ const container = document.createElement('div');
334
+ const root = createRoot(container);
335
+ const Hook = () => {
336
+ useStorageFullEntry({ client: asClient(request), target, key: 'large' });
337
+ return null;
338
+ };
339
+
340
+ await act(async () => {
341
+ root.render(
342
+ <QueryClientProvider client={queryClient}>
343
+ <Hook />
344
+ </QueryClientProvider>,
345
+ );
346
+ });
347
+ await act(async () => {
348
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
349
+ });
350
+ await act(async () => root.unmount());
351
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
352
+ });
353
+ });
@@ -1,11 +1,10 @@
1
+ import { Button, ConfirmDialog, Dialog, Field, Input } from '@rozenite/ui';
1
2
  import { useEffect, useState } from 'react';
2
- import { X } from 'lucide-react';
3
3
  import type {
4
4
  StorageEntry,
5
5
  StorageEntryType,
6
6
  StorageEntryValue,
7
7
  } from '../shared/types';
8
- import { ConfirmDialog } from './confirm-dialog';
9
8
  import { TypedValueEditor } from './typed-value-editor';
10
9
  import { defaultValueForType } from './type-conversion';
11
10
 
@@ -50,11 +49,9 @@ export const AddEntryDialog = ({
50
49
  const [currentValue, setCurrentValue] = useState<StorageEntryValue | null>(
51
50
  defaultValueForType(initialType),
52
51
  );
53
- const [confirmDialog, setConfirmDialog] = useState<{
54
- isOpen: boolean;
55
- title: string;
56
- message: string;
57
- }>({ isOpen: false, title: '', message: '' });
52
+ const [alert, setAlert] = useState<{ title: string; message: string } | null>(
53
+ null,
54
+ );
58
55
 
59
56
  // Reset state every time the dialog opens, so a previous session's
60
57
  // type / value doesn't bleed in.
@@ -79,8 +76,7 @@ export const AddEntryDialog = ({
79
76
  if (!newEntryKey.trim() || currentValue === null) return;
80
77
 
81
78
  if (!isCurrentTypeSupported) {
82
- setConfirmDialog({
83
- isOpen: true,
79
+ setAlert({
84
80
  title: 'Unsupported Type',
85
81
  message: 'Selected type is not supported by this storage.',
86
82
  });
@@ -88,8 +84,7 @@ export const AddEntryDialog = ({
88
84
  }
89
85
 
90
86
  if (existingKeys.includes(newEntryKey)) {
91
- setConfirmDialog({
92
- isOpen: true,
87
+ setAlert({
93
88
  title: 'Key Already Exists',
94
89
  message: 'An entry with this key already exists.',
95
90
  });
@@ -101,10 +96,6 @@ export const AddEntryDialog = ({
101
96
  };
102
97
 
103
98
  const handleKeyDown = (event: React.KeyboardEvent) => {
104
- if (event.key === 'Escape') {
105
- resetAndClose();
106
- return;
107
- }
108
99
  if (
109
100
  event.key === 'Enter' &&
110
101
  newEntryKey.trim() &&
@@ -114,107 +105,76 @@ export const AddEntryDialog = ({
114
105
  }
115
106
  };
116
107
 
117
- if (!isOpen) {
118
- return null;
119
- }
120
-
121
108
  // Unsavable when no key, no supported type, or when the value is
122
109
  // null — the hex editor signals invalid / empty hex via null.
123
110
  const isAddDisabled =
124
111
  !newEntryKey.trim() || !isCurrentTypeSupported || currentValue === null;
125
112
 
126
113
  return (
127
- <div
128
- className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
129
- onClick={resetAndClose}
130
- >
131
- <div
132
- className="bg-gray-800 rounded-lg p-6 w-[32rem] max-w-full mx-4"
133
- onClick={(event) => event.stopPropagation()}
134
- onKeyDown={handleKeyDown}
114
+ <>
115
+ <Dialog
116
+ open={isOpen}
117
+ onOpenChange={(open) => {
118
+ if (!open) resetAndClose();
119
+ }}
135
120
  >
136
- <div className="flex items-center justify-between mb-4">
137
- <h2 className="text-lg font-semibold text-gray-100">Add New Entry</h2>
138
- <button
139
- onClick={resetAndClose}
140
- className="p-1 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
141
- title="Close dialog"
142
- >
143
- <X className="h-4 w-4" />
144
- </button>
145
- </div>
146
-
147
- <div className="space-y-4">
148
- <div>
149
- <label
150
- htmlFor="new-entry-key"
151
- className="block text-sm font-medium text-gray-200 mb-1"
152
- >
153
- Key
154
- </label>
155
- <input
156
- id="new-entry-key"
157
- type="text"
158
- value={newEntryKey}
159
- onChange={(event) => setNewEntryKey(event.target.value)}
160
- placeholder="Enter key name"
161
- 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"
162
- autoFocus
163
- />
121
+ <Dialog.Content onKeyDown={handleKeyDown}>
122
+ <Dialog.Header>
123
+ <Dialog.Title>Add New Entry</Dialog.Title>
124
+ </Dialog.Header>
125
+
126
+ <div className="flex flex-col gap-4">
127
+ <Field>
128
+ <Field.Label htmlFor="new-entry-key">Key</Field.Label>
129
+ <Input
130
+ id="new-entry-key"
131
+ value={newEntryKey}
132
+ onChange={(event) => setNewEntryKey(event.target.value)}
133
+ placeholder="Enter key name"
134
+ autoFocus
135
+ />
136
+ </Field>
137
+
138
+ <Field>
139
+ <Field.Label htmlFor="new-entry-value">Value</Field.Label>
140
+ <TypedValueEditor
141
+ supportedTypes={supportedTypes}
142
+ type={currentType}
143
+ value={currentValue}
144
+ onChange={(nextType, nextValue) => {
145
+ setCurrentType(nextType);
146
+ setCurrentValue(nextValue);
147
+ }}
148
+ inputId="new-entry-value"
149
+ />
150
+ {!isCurrentTypeSupported && (
151
+ <Field.Description className="text-destructive">
152
+ Selected type is not supported by this storage.
153
+ </Field.Description>
154
+ )}
155
+ </Field>
164
156
  </div>
165
157
 
166
- <div>
167
- <label
168
- htmlFor="new-entry-value"
169
- className="block text-sm font-medium text-gray-200 mb-1"
170
- >
171
- Value
172
- </label>
173
- <TypedValueEditor
174
- supportedTypes={supportedTypes}
175
- type={currentType}
176
- value={currentValue}
177
- onChange={(nextType, nextValue) => {
178
- setCurrentType(nextType);
179
- setCurrentValue(nextValue);
180
- }}
181
- inputId="new-entry-value"
182
- />
183
- {!isCurrentTypeSupported && (
184
- <p className="text-xs text-amber-400 mt-1">
185
- Selected type is not supported by this storage.
186
- </p>
187
- )}
188
- </div>
189
- </div>
190
-
191
- <div className="flex items-center justify-end gap-2 mt-6">
192
- <button
193
- onClick={resetAndClose}
194
- className="px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded transition-colors"
195
- >
196
- Cancel
197
- </button>
198
- <button
199
- onClick={handleAdd}
200
- disabled={isAddDisabled}
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"
202
- >
203
- Add Entry
204
- </button>
205
- </div>
206
- </div>
158
+ <Dialog.Footer>
159
+ <Button variant="outline" onClick={resetAndClose}>
160
+ Cancel
161
+ </Button>
162
+ <Button onClick={handleAdd} disabled={isAddDisabled}>
163
+ Add Entry
164
+ </Button>
165
+ </Dialog.Footer>
166
+ </Dialog.Content>
167
+ </Dialog>
207
168
 
208
169
  <ConfirmDialog
209
- isOpen={confirmDialog.isOpen}
210
- onClose={() =>
211
- setConfirmDialog((previous) => ({ ...previous, isOpen: false }))
212
- }
213
- onConfirm={() => {}}
214
- title={confirmDialog.title}
215
- message={confirmDialog.message}
216
- type="alert"
170
+ open={alert !== null}
171
+ onOpenChange={(open) => {
172
+ if (!open) setAlert(null);
173
+ }}
174
+ variant="alert"
175
+ title={alert?.title ?? ''}
176
+ description={alert?.message}
217
177
  />
218
- </div>
178
+ </>
219
179
  );
220
180
  };
@@ -17,7 +17,8 @@ export type EditorState = {
17
17
  export type EditorAction =
18
18
  | { type: 'set-text'; text: string }
19
19
  | { type: 'normalize-paste'; text: string }
20
- | { type: 'switch-mode'; mode: EditorMode };
20
+ | { type: 'switch-mode'; mode: EditorMode }
21
+ | { type: 'replace-bytes'; bytes: number[] };
21
22
 
22
23
  export type Validation =
23
24
  | { ok: true; bytes: number[] }
@@ -108,6 +109,14 @@ export const reduce = (
108
109
  error: null,
109
110
  };
110
111
  }
112
+ case 'replace-bytes': {
113
+ return {
114
+ mode: state.mode,
115
+ text: encode(action.bytes, state.mode),
116
+ bytes: action.bytes,
117
+ error: null,
118
+ };
119
+ }
111
120
  }
112
121
  };
113
122