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