@rozenite/storage-plugin 1.12.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.
- package/CHANGELOG.md +37 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
- package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -64
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +22 -14
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
- package/dist/react-native/index.d.ts +154 -18
- package/dist/react-native/index.js +10 -2
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +13 -15
- package/react-native.ts +29 -14
- package/rozenite.config.ts +78 -0
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
- package/src/react-native/__tests__/import.test.ts +172 -10
- package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +153 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
- package/src/react-native/adapters/async-storage.ts +9 -7
- package/src/react-native/adapters/mmkv.ts +5 -0
- package/src/react-native/adapters/secure-storage.ts +9 -1
- package/src/react-native/entry-preview-pagination.ts +277 -0
- package/src/react-native/export-snapshot.ts +94 -0
- package/src/react-native/full-entry-request.ts +96 -0
- package/src/react-native/import.ts +190 -16
- package/src/react-native/storage-discovery.ts +96 -0
- package/src/react-native/storage-view.ts +45 -103
- package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
- package/src/react-native/useStorageAgentTools.ts +207 -86
- package/src/shared/__tests__/entry-preview.test.ts +117 -0
- package/src/shared/__tests__/snapshot.test.ts +21 -0
- package/src/shared/agent-tools.ts +27 -11
- package/src/shared/entry-preview.ts +59 -0
- package/src/shared/messaging.ts +136 -21
- package/src/shared/snapshot.ts +8 -1
- package/src/shared/types.ts +20 -1
- package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
- package/src/ui/__tests__/binary.test.ts +18 -0
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
- package/src/ui/__tests__/panel.test.tsx +409 -0
- package/src/ui/__tests__/storage-groups.test.ts +91 -0
- package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
- package/src/ui/add-entry-dialog.tsx +65 -105
- package/src/ui/binary-value-editor-state.ts +10 -1
- package/src/ui/binary-value-editor.tsx +57 -33
- package/src/ui/binary.ts +56 -4
- package/src/ui/edit-entry-dialog.tsx +71 -99
- package/src/ui/editor-switcher.tsx +7 -5
- package/src/ui/entry-detail-dialog.tsx +69 -179
- package/src/ui/format-value.tsx +32 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +66 -91
- package/src/ui/large-value-viewer-state.ts +32 -0
- package/src/ui/large-value-viewer.tsx +71 -0
- package/src/ui/panel.tsx +724 -496
- package/src/ui/query-client.tsx +34 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/typed-value-editor.tsx +14 -15
- package/src/ui/use-storage-entries.ts +207 -0
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- 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 [
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
<
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
<
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
>
|
|
171
|
-
|
|
172
|
-
</
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
title={
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|