@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +37 -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,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ MAX_BUFFER_PREVIEW_BYTES,
4
+ MAX_STRING_PREVIEW_CODE_POINTS,
5
+ toStorageEntryPreview,
6
+ } from '../entry-preview';
7
+
8
+ describe('toStorageEntryPreview', () => {
9
+ it('represents empty and boundary-sized strings completely', () => {
10
+ expect(
11
+ toStorageEntryPreview({ key: 'empty', type: 'string', value: '' }),
12
+ ).toEqual({
13
+ key: 'empty',
14
+ type: 'string',
15
+ preview: '',
16
+ valueSize: 0,
17
+ isTruncated: false,
18
+ });
19
+
20
+ const value = 'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS);
21
+ expect(
22
+ toStorageEntryPreview({ key: 'boundary', type: 'string', value }),
23
+ ).toEqual({
24
+ key: 'boundary',
25
+ type: 'string',
26
+ preview: value,
27
+ valueSize: MAX_STRING_PREVIEW_CODE_POINTS,
28
+ isTruncated: false,
29
+ });
30
+ });
31
+
32
+ it('truncates an oversized string at the fixed code-point boundary', () => {
33
+ const value = `${'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS)}z`;
34
+
35
+ expect(
36
+ toStorageEntryPreview({ key: 'long', type: 'string', value }),
37
+ ).toEqual({
38
+ key: 'long',
39
+ type: 'string',
40
+ preview: 'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS),
41
+ valueSize: MAX_STRING_PREVIEW_CODE_POINTS + 1,
42
+ isTruncated: true,
43
+ });
44
+ });
45
+
46
+ it('does not split Unicode code points while preserving string length', () => {
47
+ const value = `${'😀'.repeat(MAX_STRING_PREVIEW_CODE_POINTS)}!`;
48
+
49
+ expect(
50
+ toStorageEntryPreview({ key: 'emoji', type: 'string', value }),
51
+ ).toEqual({
52
+ key: 'emoji',
53
+ type: 'string',
54
+ preview: '😀'.repeat(MAX_STRING_PREVIEW_CODE_POINTS),
55
+ valueSize: value.length,
56
+ isTruncated: true,
57
+ });
58
+ });
59
+
60
+ it('formats primitive values completely', () => {
61
+ expect(
62
+ toStorageEntryPreview({ key: 'count', type: 'number', value: 42 }),
63
+ ).toEqual({
64
+ key: 'count',
65
+ type: 'number',
66
+ preview: '42',
67
+ valueSize: 2,
68
+ isTruncated: false,
69
+ });
70
+ expect(
71
+ toStorageEntryPreview({ key: 'enabled', type: 'boolean', value: false }),
72
+ ).toEqual({
73
+ key: 'enabled',
74
+ type: 'boolean',
75
+ preview: 'false',
76
+ valueSize: 5,
77
+ isTruncated: false,
78
+ });
79
+ });
80
+
81
+ it('formats empty and boundary-sized buffers as hexadecimal', () => {
82
+ expect(
83
+ toStorageEntryPreview({ key: 'empty', type: 'buffer', value: [] }),
84
+ ).toEqual({
85
+ key: 'empty',
86
+ type: 'buffer',
87
+ preview: '',
88
+ valueSize: 0,
89
+ isTruncated: false,
90
+ });
91
+
92
+ const value = new Array(MAX_BUFFER_PREVIEW_BYTES).fill(0xab);
93
+ expect(
94
+ toStorageEntryPreview({ key: 'boundary', type: 'buffer', value }),
95
+ ).toEqual({
96
+ key: 'boundary',
97
+ type: 'buffer',
98
+ preview: new Array(MAX_BUFFER_PREVIEW_BYTES).fill('AB').join(' '),
99
+ valueSize: MAX_BUFFER_PREVIEW_BYTES,
100
+ isTruncated: false,
101
+ });
102
+ });
103
+
104
+ it('truncates oversized buffers at the fixed byte boundary', () => {
105
+ const value = [...new Array(MAX_BUFFER_PREVIEW_BYTES).fill(0xab), 0xcd];
106
+
107
+ expect(
108
+ toStorageEntryPreview({ key: 'long', type: 'buffer', value }),
109
+ ).toEqual({
110
+ key: 'long',
111
+ type: 'buffer',
112
+ preview: new Array(MAX_BUFFER_PREVIEW_BYTES).fill('AB').join(' '),
113
+ valueSize: MAX_BUFFER_PREVIEW_BYTES + 1,
114
+ isTruncated: true,
115
+ });
116
+ });
117
+ });
@@ -358,4 +358,25 @@ describe('computePreview', () => {
358
358
  });
359
359
  expect(preview.metadataMismatch).toBe(false);
360
360
  });
361
+
362
+ it('treats accepted duplicate keys as sequential overwrites', () => {
363
+ const preview = computePreview(
364
+ validSnapshot({
365
+ entries: [
366
+ { key: 'duplicate', type: 'string', value: 'first' },
367
+ { key: 'duplicate', type: 'string', value: 'second' },
368
+ ],
369
+ }),
370
+ {
371
+ target,
372
+ capabilities: allTypes,
373
+ entryKeys: new Set(),
374
+ isBlacklisted: noBlacklist,
375
+ },
376
+ );
377
+
378
+ expect(preview.newKeys).toEqual(['duplicate']);
379
+ expect(preview.overwriteKeys).toEqual(['duplicate']);
380
+ expect(preview.acceptedEntryIndexes).toEqual([0, 1]);
381
+ });
361
382
  });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  defineAgentToolContract,
3
+ definePaginatedAgentToolContract,
3
4
  type AgentToolContract,
4
5
  } from '@rozenite/agent-shared';
5
6
 
@@ -36,7 +37,6 @@ export type StorageListStoragesItem = {
36
37
  storageId: string;
37
38
  adapterName: string;
38
39
  storageName: string;
39
- entryCount: number;
40
40
  supportedTypes: StorageEntryType[];
41
41
  };
42
42
 
@@ -45,17 +45,22 @@ export type StorageListStoragesResult = {
45
45
  };
46
46
 
47
47
  export type StorageListEntriesArgs = StorageSelection & {
48
- offset?: number;
49
48
  limit?: number;
49
+ cursor?: string;
50
+ /** @deprecated Use the opaque cursor returned by a previous page. */
51
+ offset?: number;
50
52
  };
51
53
 
52
54
  export type StorageListEntriesResult = {
53
55
  adapterId: string;
54
56
  storageId: string;
55
57
  total: number;
56
- offset: number;
57
- limit: number;
58
- keys: string[];
58
+ items: Array<{ key: string }>;
59
+ page: {
60
+ limit: number;
61
+ hasMore: boolean;
62
+ nextCursor?: string;
63
+ };
59
64
  };
60
65
 
61
66
  export type StorageReadEntryArgs = StorageSelection & {
@@ -106,10 +111,10 @@ export const storageToolDefinitions = {
106
111
  >({
107
112
  name: 'list-storages',
108
113
  description:
109
- 'List all storage adapters and their storage nodes currently available on the device, including supported entry types and entry counts.',
114
+ 'List all storage adapters and their storage nodes currently available on the device, including supported entry types. Entry counts are omitted to avoid enumerating every storage.',
110
115
  inputSchema: { type: 'object', properties: {} },
111
116
  }),
112
- listEntries: defineAgentToolContract<
117
+ listEntries: definePaginatedAgentToolContract<
113
118
  StorageListEntriesArgs,
114
119
  StorageListEntriesResult
115
120
  >({
@@ -120,16 +125,27 @@ export const storageToolDefinitions = {
120
125
  type: 'object',
121
126
  properties: {
122
127
  ...sharedStorageProperties,
123
- offset: {
128
+ limit: {
124
129
  type: 'number',
125
- description: 'Pagination offset. Defaults to 0.',
130
+ description: 'Pagination size. Defaults to 20 and is capped at 100.',
126
131
  },
127
- limit: {
132
+ cursor: {
133
+ type: 'string',
134
+ description:
135
+ 'Opaque pagination cursor from a previous list-entries call.',
136
+ },
137
+ offset: {
128
138
  type: 'number',
129
- description: 'Pagination size. Defaults to 100.',
139
+ description:
140
+ 'Deprecated initial pagination offset. Must be a non-negative integer and cannot be combined with cursor.',
130
141
  },
131
142
  },
132
143
  },
144
+ pagination: {
145
+ kind: 'cursor',
146
+ fields: ['key'],
147
+ defaultFields: ['key'],
148
+ },
133
149
  }),
134
150
  readEntry: defineAgentToolContract<
135
151
  StorageReadEntryArgs,
@@ -0,0 +1,59 @@
1
+ import type { StorageEntry, StorageEntryPreview } from './types';
2
+
3
+ export const MAX_STRING_PREVIEW_CODE_POINTS = 256;
4
+ export const MAX_BUFFER_PREVIEW_BYTES = 16;
5
+
6
+ const toHexPair = (byte: number): string =>
7
+ byte.toString(16).toUpperCase().padStart(2, '0');
8
+
9
+ const truncateString = (
10
+ value: string,
11
+ ): Pick<StorageEntryPreview, 'preview' | 'isTruncated'> => {
12
+ let preview = '';
13
+ let codePointCount = 0;
14
+
15
+ for (const codePoint of value) {
16
+ if (codePointCount === MAX_STRING_PREVIEW_CODE_POINTS) {
17
+ return { preview, isTruncated: true };
18
+ }
19
+
20
+ preview += codePoint;
21
+ codePointCount += 1;
22
+ }
23
+
24
+ return { preview, isTruncated: false };
25
+ };
26
+
27
+ export const toStorageEntryPreview = (
28
+ entry: StorageEntry,
29
+ ): StorageEntryPreview => {
30
+ if (entry.type === 'string') {
31
+ return {
32
+ key: entry.key,
33
+ type: entry.type,
34
+ ...truncateString(entry.value),
35
+ // JavaScript strings expose their length in UTF-16 code units.
36
+ valueSize: entry.value.length,
37
+ };
38
+ }
39
+
40
+ if (entry.type === 'buffer') {
41
+ const shownBytes = entry.value.slice(0, MAX_BUFFER_PREVIEW_BYTES);
42
+ return {
43
+ key: entry.key,
44
+ type: entry.type,
45
+ preview: shownBytes.map(toHexPair).join(' '),
46
+ valueSize: entry.value.length,
47
+ isTruncated: entry.value.length > MAX_BUFFER_PREVIEW_BYTES,
48
+ };
49
+ }
50
+
51
+ const preview = String(entry.value);
52
+ return {
53
+ key: entry.key,
54
+ type: entry.type,
55
+ preview,
56
+ valueSize: preview.length,
57
+ isTruncated: false,
58
+ };
59
+ };
@@ -1,19 +1,10 @@
1
- import type { StorageCapabilities, StorageEntry, StorageTarget } from './types';
2
-
3
- export type SerializedBlacklist = {
4
- source: string;
5
- flags: string;
6
- };
7
-
8
- export type StorageSnapshotEvent = {
9
- type: 'snapshot';
10
- target: StorageTarget;
11
- adapterName: string;
12
- storageName: string;
13
- capabilities: StorageCapabilities;
14
- blacklist?: SerializedBlacklist;
15
- entries: StorageEntry[];
16
- };
1
+ import type {
2
+ StorageDescriptor,
3
+ StorageEntry,
4
+ StorageEntryPreview,
5
+ StorageTarget,
6
+ } from './types';
7
+ import type { ImportPreview, StorageSnapshotV1 } from './snapshot';
17
8
 
18
9
  export type StorageSetEntryEvent = {
19
10
  type: 'set-entry';
@@ -27,19 +18,131 @@ export type StorageDeleteEntryEvent = {
27
18
  key: string;
28
19
  };
29
20
 
30
- export type StorageGetSnapshotEvent = {
31
- type: 'get-snapshot';
32
- target: StorageTarget | 'all';
21
+ export type StoragePurgeEvent = {
22
+ type: 'purge-storage';
23
+ target: StorageTarget;
24
+ };
25
+
26
+ export type StorageInvalidationOperation =
27
+ | 'set'
28
+ | 'delete'
29
+ | 'import'
30
+ | 'purge';
31
+
32
+ export type StorageInvalidatedEvent = {
33
+ type: 'storage-invalidated';
34
+ target: StorageTarget;
35
+ /** Native subscriptions only expose a key, not the operation. */
36
+ key?: string;
37
+ operation?: StorageInvalidationOperation;
38
+ };
39
+
40
+ export type StorageDiscoverStoragesRequestEvent = {
41
+ type: 'discover-storages';
42
+ requestId: string;
43
+ };
44
+
45
+ export type StorageDiscoverStoragesResponseEvent = {
46
+ type: 'storage-descriptors';
47
+ requestId: string;
48
+ storages: StorageDescriptor[];
49
+ };
50
+
51
+ export type StorageListEntryPreviewsRequestEvent = {
52
+ type: 'list-entry-previews';
53
+ requestId: string;
54
+ target: StorageTarget;
55
+ search?: string;
56
+ keySortDirection?: 'ascending' | 'descending';
57
+ cursor?: string;
58
+ limit: number;
59
+ };
60
+
61
+ export type StorageListEntryPreviewsResponseEvent = {
62
+ type: 'entry-previews';
63
+ requestId: string;
64
+ target: StorageTarget;
65
+ items: StorageEntryPreview[];
66
+ nextCursor?: string;
67
+ previousCursor?: string;
68
+ };
69
+
70
+ export type StorageGetEntryRequestEvent = {
71
+ type: 'get-entry';
72
+ requestId: string;
73
+ target: StorageTarget;
74
+ key: string;
75
+ };
76
+
77
+ export type StorageGetEntryResponseEvent = {
78
+ type: 'entry';
79
+ requestId: string;
80
+ target: StorageTarget;
81
+ entry: StorageEntry;
82
+ };
83
+
84
+ export type StorageExportSnapshotRequestEvent = {
85
+ type: 'export-snapshot';
86
+ requestId: string;
87
+ target: StorageTarget;
88
+ };
89
+
90
+ export type StorageExportSnapshotResponseEvent = {
91
+ type: 'export-snapshot-result';
92
+ requestId: string;
93
+ target: StorageTarget;
94
+ snapshot: StorageSnapshotV1;
95
+ };
96
+
97
+ export type StorageImportPreviewRequestEvent = {
98
+ type: 'preview-import';
99
+ requestId: string;
100
+ target: StorageTarget;
101
+ snapshot: StorageSnapshotV1;
102
+ };
103
+
104
+ export type StorageImportPreviewResponseEvent = {
105
+ type: 'import-preview';
106
+ requestId: string;
107
+ target: StorageTarget;
108
+ preview: ImportPreview;
109
+ };
110
+
111
+ export type StorageRequestError = {
112
+ requestId: string;
113
+ code:
114
+ | 'TARGET_NOT_FOUND'
115
+ | 'ENTRY_NOT_FOUND'
116
+ | 'INVALID_CURSOR'
117
+ | 'INVALID_REQUEST'
118
+ | 'READ_FAILED'
119
+ | 'WRITE_FAILED';
120
+ message: string;
121
+ resetPagination?: boolean;
122
+ };
123
+
124
+ export type StorageRequestErrorEvent = StorageRequestError & {
125
+ type: 'storage-request-error';
33
126
  };
34
127
 
35
128
  export type StorageImportEntriesEvent = {
36
129
  type: 'import-entries';
130
+ requestId: string;
37
131
  target: StorageTarget;
38
132
  entries: StorageEntry[];
39
133
  };
40
134
 
135
+ export type StorageImportProgressEvent = {
136
+ type: 'import-progress';
137
+ requestId: string;
138
+ target: StorageTarget;
139
+ written: number;
140
+ total: number;
141
+ };
142
+
41
143
  export type StorageImportResultEvent = {
42
144
  type: 'import-result';
145
+ requestId: string;
43
146
  target: StorageTarget;
44
147
  ok: boolean;
45
148
  written: number;
@@ -49,11 +152,23 @@ export type StorageImportResultEvent = {
49
152
  };
50
153
 
51
154
  export type StorageEvent =
52
- | StorageSnapshotEvent
53
155
  | StorageSetEntryEvent
54
156
  | StorageDeleteEntryEvent
55
- | StorageGetSnapshotEvent
157
+ | StoragePurgeEvent
158
+ | StorageInvalidatedEvent
159
+ | StorageDiscoverStoragesRequestEvent
160
+ | StorageDiscoverStoragesResponseEvent
161
+ | StorageListEntryPreviewsRequestEvent
162
+ | StorageListEntryPreviewsResponseEvent
163
+ | StorageGetEntryRequestEvent
164
+ | StorageGetEntryResponseEvent
165
+ | StorageExportSnapshotRequestEvent
166
+ | StorageExportSnapshotResponseEvent
167
+ | StorageImportPreviewRequestEvent
168
+ | StorageImportPreviewResponseEvent
169
+ | StorageRequestErrorEvent
56
170
  | StorageImportEntriesEvent
171
+ | StorageImportProgressEvent
57
172
  | StorageImportResultEvent;
58
173
 
59
174
  export type StorageEventMap = {
@@ -229,6 +229,7 @@ export type ImportPreview = {
229
229
  overwriteKeys: string[];
230
230
  skippedKeys: { key: string; reason: 'blacklist' }[];
231
231
  unsupportedTypes: { key: string; type: StorageEntryType }[];
232
+ acceptedEntryIndexes: number[];
232
233
  metadataMismatch: boolean;
233
234
  };
234
235
 
@@ -245,8 +246,9 @@ export const computePreview = (
245
246
  const overwriteKeys: string[] = [];
246
247
  const skippedKeys: { key: string; reason: 'blacklist' }[] = [];
247
248
  const unsupportedTypes: { key: string; type: StorageEntryType }[] = [];
249
+ const acceptedEntryIndexes: number[] = [];
248
250
 
249
- for (const entry of snapshot.entries) {
251
+ for (const [index, entry] of snapshot.entries.entries()) {
250
252
  if (!supportsType(current.capabilities, entry.type)) {
251
253
  unsupportedTypes.push({ key: entry.key, type: entry.type });
252
254
  continue;
@@ -260,6 +262,10 @@ export const computePreview = (
260
262
  } else {
261
263
  newKeys.push(entry.key);
262
264
  }
265
+ acceptedEntryIndexes.push(index);
266
+ // Duplicate keys are applied in source order, so every accepted duplicate
267
+ // after the first overwrites the value that preceded it in this import.
268
+ current.entryKeys.add(entry.key);
263
269
  }
264
270
 
265
271
  const metadataMismatch =
@@ -271,6 +277,7 @@ export const computePreview = (
271
277
  overwriteKeys,
272
278
  skippedKeys,
273
279
  unsupportedTypes,
280
+ acceptedEntryIndexes,
274
281
  metadataMismatch,
275
282
  };
276
283
  };
@@ -7,10 +7,27 @@ export type StorageEntry =
7
7
  export type StorageEntryType = StorageEntry['type'];
8
8
  export type StorageEntryValue = StorageEntry['value'];
9
9
 
10
+ export type StorageEntryPreview = {
11
+ key: string;
12
+ type: StorageEntryType;
13
+ preview: string;
14
+ valueSize: number;
15
+ isTruncated: boolean;
16
+ };
17
+
10
18
  export type StorageCapabilities = {
11
19
  supportedTypes: StorageEntryType[];
12
20
  };
13
21
 
22
+ export type StorageDescriptor = {
23
+ target: StorageTarget;
24
+ adapterName: string;
25
+ storageName: string;
26
+ entryCount: number;
27
+ capabilities: StorageCapabilities;
28
+ supportsSubscriptions: boolean;
29
+ };
30
+
14
31
  export type StorageSubscription = { remove: () => void };
15
32
 
16
33
  export type SyncStorage = {
@@ -19,6 +36,7 @@ export type SyncStorage = {
19
36
  get: (key: string) => StorageEntry | undefined;
20
37
  set: (entry: StorageEntry) => void;
21
38
  delete: (key: string) => void;
39
+ clear: () => void;
22
40
  subscribe?: (callback: (key: string) => void) => StorageSubscription;
23
41
  };
24
42
 
@@ -28,6 +46,7 @@ export type AsyncStorage = {
28
46
  get: (key: string) => Promise<StorageEntry | undefined>;
29
47
  set: (entry: StorageEntry) => Promise<void>;
30
48
  delete: (key: string) => Promise<void>;
49
+ clear: () => Promise<void>;
31
50
  subscribe?: (callback: (key: string) => void) => StorageSubscription;
32
51
  };
33
52
 
@@ -63,5 +82,5 @@ export const getStorageViewId = ({ adapterId, storageId }: StorageTarget) =>
63
82
 
64
83
  export const supportsType = (
65
84
  capabilities: StorageCapabilities,
66
- type: StorageEntryType
85
+ type: StorageEntryType,
67
86
  ) => capabilities.supportedTypes.includes(type);
@@ -176,6 +176,19 @@ describe('reduce: switch-mode', () => {
176
176
  });
177
177
  });
178
178
 
179
+ describe('reduce: replace-bytes', () => {
180
+ it('formats fetched bytes after the editor has had a chance to paint', () => {
181
+ expect(
182
+ reduce(hexState(), { type: 'replace-bytes', bytes: [0x48, 0x69] }),
183
+ ).toEqual({
184
+ mode: 'hex',
185
+ text: '48 69',
186
+ bytes: [0x48, 0x69],
187
+ error: null,
188
+ });
189
+ });
190
+ });
191
+
179
192
  describe('validate', () => {
180
193
  it('passes when bytes are valid and non-empty', () => {
181
194
  const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
@@ -5,7 +5,9 @@ import {
5
5
  bytesToBase64,
6
6
  bytesToGroupedHex,
7
7
  bytesToHexdump,
8
+ compactAsciiPreview,
8
9
  compactBufferPreview,
10
+ formatHexdumpRow,
9
11
  hexInputToBytes,
10
12
  } from '../binary';
11
13
 
@@ -80,6 +82,16 @@ describe('bytesToHexdump', () => {
80
82
  });
81
83
  });
82
84
 
85
+ describe('formatHexdumpRow', () => {
86
+ it('formats one requested row without requiring a complete hexdump', () => {
87
+ expect(formatHexdumpRow(new Array(32).fill(0x41), 16)).toEqual({
88
+ offset: '00000010',
89
+ hex: '41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41',
90
+ ascii: 'AAAAAAAAAAAAAAAA',
91
+ });
92
+ });
93
+ });
94
+
83
95
  describe('bytesToAsciiPreview', () => {
84
96
  it('maps printable bytes to characters', () => {
85
97
  expect(bytesToAsciiPreview([0x48, 0x69])).toBe('Hi');
@@ -94,6 +106,12 @@ describe('bytesToAsciiPreview', () => {
94
106
  });
95
107
  });
96
108
 
109
+ describe('compactAsciiPreview', () => {
110
+ it('only converts the visible prefix of a large buffer', () => {
111
+ expect(compactAsciiPreview(new Array(128).fill(0x41), 4)).toBe('AAAA…');
112
+ });
113
+ });
114
+
97
115
  describe('bytesToBase64 / base64ToBytes round-trip', () => {
98
116
  it('encodes simple ASCII to base64 and back', () => {
99
117
  const bytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello"
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TEXT_CHUNK_SIZE, textRowRanges } from '../large-value-viewer-state';
3
+
4
+ describe('textRowRanges', () => {
5
+ it('indexes chunks without splitting a pathological single line', () => {
6
+ const value = 'x'.repeat(TEXT_CHUNK_SIZE * 2 + 7);
7
+
8
+ expect(textRowRanges(value)).toEqual([
9
+ [0, TEXT_CHUNK_SIZE],
10
+ [TEXT_CHUNK_SIZE, TEXT_CHUNK_SIZE * 2],
11
+ [TEXT_CHUNK_SIZE * 2, value.length],
12
+ ]);
13
+ });
14
+
15
+ it('keeps newline boundaries in their displayed row', () => {
16
+ expect(textRowRanges('first\nsecond')).toEqual([
17
+ [0, 6],
18
+ [6, 12],
19
+ ]);
20
+ });
21
+ });