@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
package/src/ui/panel.tsx CHANGED
@@ -1,48 +1,67 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import type {
3
+ ColumnDef,
4
+ OnChangeFn,
5
+ SortingState,
6
+ } from '@tanstack/react-table';
1
7
  import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
- import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { Download, Plus, Search, Upload } from 'lucide-react';
8
+ import {
9
+ Badge,
10
+ Button,
11
+ ConfirmDialog,
12
+ EmptyState,
13
+ PluginShell,
14
+ SearchField,
15
+ Sidebar,
16
+ Split,
17
+ Toolbar,
18
+ VirtualizedDataTable,
19
+ } from '@rozenite/ui';
20
+ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
21
+ import {
22
+ Database,
23
+ Download,
24
+ Edit3,
25
+ Plus,
26
+ RefreshCw,
27
+ Trash2,
28
+ Upload,
29
+ } from 'lucide-react';
4
30
  import type {
5
- StorageDeleteEntryEvent,
31
+ StorageDiscoverStoragesResponseEvent,
6
32
  StorageEventMap,
33
+ StorageImportProgressEvent,
7
34
  StorageImportResultEvent,
8
- StorageSetEntryEvent,
9
- StorageSnapshotEvent,
35
+ StorageInvalidatedEvent,
10
36
  } from '../shared/messaging';
37
+ import { parseSnapshot } from '../shared/snapshot';
11
38
  import type {
12
- StorageCapabilities,
39
+ StorageDescriptor,
13
40
  StorageEntry,
41
+ StorageEntryPreview,
14
42
  StorageEntryValue,
15
43
  StorageTarget,
16
44
  } from '../shared/types';
17
45
  import { getStorageViewId } from '../shared/types';
18
- import {
19
- buildSnapshot,
20
- computePreview,
21
- parseSnapshot,
22
- } from '../shared/snapshot';
23
- import { EditableTable } from './editable-table';
24
46
  import { AddEntryDialog } from './add-entry-dialog';
25
- import { EntryDetailDialog } from './entry-detail-dialog';
26
47
  import { EditEntryDialog } from './edit-entry-dialog';
27
- import { ConfirmDialog } from './confirm-dialog';
48
+ import { EntryDetailDialog } from './entry-detail-dialog';
28
49
  import { ImportDialog, type ImportFlightState } from './import-dialog';
50
+ import { StorageQueryClientProvider } from './query-client';
51
+ import { buildStorageSidebarGroups } from './storage-groups';
52
+ import {
53
+ flattenStorageEntryPreviewPages,
54
+ dropStorageFullEntries,
55
+ invalidateStorageEntryPreviews,
56
+ resetSelectedStorageQueries,
57
+ useStorageEntryPreviews,
58
+ useStorageFullEntry,
59
+ } from './use-storage-entries';
29
60
  import { buildExportFilename, downloadJson } from './utils';
30
61
  import './globals.css';
31
62
 
32
- type StorageSnapshotState = {
33
- target: StorageTarget;
34
- adapterName: string;
35
- storageName: string;
36
- capabilities: StorageCapabilities;
37
- blacklist?: RegExp;
38
- entries: StorageEntry[];
39
- };
40
-
41
- type AlertState = {
42
- isOpen: boolean;
43
- title: string;
44
- message: string;
45
- };
63
+ type AlertState = { title: string; message: string };
64
+ type FullEntryInteraction = { key: string; mode: 'detail' | 'edit' };
46
65
 
47
66
  const sameTarget = (a: StorageTarget, b: StorageTarget) =>
48
67
  a.adapterId === b.adapterId && a.storageId === b.storageId;
@@ -50,349 +69,327 @@ const sameTarget = (a: StorageTarget, b: StorageTarget) =>
50
69
  const getEntryTypeFromValue = (
51
70
  value: StorageEntryValue,
52
71
  ): StorageEntry['type'] => {
53
- if (typeof value === 'string') {
54
- return 'string';
55
- }
72
+ if (typeof value === 'string') return 'string';
73
+ if (typeof value === 'number') return 'number';
74
+ if (typeof value === 'boolean') return 'boolean';
75
+ return 'buffer';
76
+ };
56
77
 
57
- if (typeof value === 'number') {
58
- return 'number';
59
- }
78
+ const useDebouncedValue = (value: string, delay = 250) => {
79
+ const [debouncedValue, setDebouncedValue] = useState(value);
60
80
 
61
- if (typeof value === 'boolean') {
62
- return 'boolean';
63
- }
81
+ useEffect(() => {
82
+ const timeout = window.setTimeout(() => setDebouncedValue(value), delay);
83
+ return () => window.clearTimeout(timeout);
84
+ }, [delay, value]);
64
85
 
65
- return 'buffer';
86
+ return debouncedValue;
66
87
  };
67
88
 
68
- export default function StoragePanel() {
69
- const [snapshots, setSnapshots] = useState<Map<string, StorageSnapshotState>>(
70
- new Map(),
89
+ function StoragePanelContent() {
90
+ const [descriptors, setDescriptors] = useState<StorageDescriptor[]>([]);
91
+ const [selectedTarget, setSelectedTarget] = useState<StorageTarget | null>(
92
+ null,
71
93
  );
72
- const [selectedStorageViewId, setSelectedStorageViewId] = useState<
73
- string | null
74
- >(null);
75
- const [loading, setLoading] = useState(false);
76
94
  const [searchTerm, setSearchTerm] = useState('');
95
+ const [keySortDirection, setKeySortDirection] = useState<
96
+ 'ascending' | 'descending'
97
+ >('ascending');
98
+ const [interaction, setInteraction] = useState<FullEntryInteraction | null>(
99
+ null,
100
+ );
77
101
  const [showAddDialog, setShowAddDialog] = useState(false);
78
- const [selectedEntry, setSelectedEntry] = useState<StorageEntry | null>(null);
79
- const [showDetailDialog, setShowDetailDialog] = useState(false);
80
- const [editingEntry, setEditingEntry] = useState<StorageEntry | null>(null);
81
- const [showEditDialog, setShowEditDialog] = useState(false);
102
+ const [deleteKey, setDeleteKey] = useState<string | null>(null);
103
+ const [showPurgeDialog, setShowPurgeDialog] = useState(false);
104
+ const [virtualListVersion, setVirtualListVersion] = useState(0);
105
+ const [exportState, setExportState] = useState<
106
+ | { status: 'idle' }
107
+ | { status: 'loading' }
108
+ | { status: 'error'; message: string }
109
+ >({ status: 'idle' });
82
110
  const [importFlight, setImportFlight] = useState<ImportFlightState | null>(
83
111
  null,
84
112
  );
85
- const [alertState, setAlertState] = useState<AlertState>({
86
- isOpen: false,
87
- title: '',
88
- message: '',
89
- });
113
+ const [alertState, setAlertState] = useState<AlertState | null>(null);
90
114
  const fileInputRef = useRef<HTMLInputElement | null>(null);
91
-
115
+ const selectedTargetRef = useRef<StorageTarget | null>(null);
116
+ const discoveryRequestIdRef = useRef(0);
117
+ const exportAbortControllerRef = useRef<AbortController | null>(null);
118
+ const importPreviewAbortControllerRef = useRef<AbortController | null>(null);
119
+ const importRequestIdRef = useRef(0);
120
+ const activeImportRequestIdRef = useRef<string | null>(null);
121
+ const isFetchingNextPageRef = useRef(false);
122
+ const isFetchingPreviousPageRef = useRef(false);
123
+ const pendingInvalidationsRef = useRef(new Set<string>());
92
124
  const client = useRozeniteDevToolsClient<StorageEventMap>({
93
125
  pluginId: '@rozenite/storage-plugin',
94
126
  });
127
+ const queryClient = useQueryClient();
128
+ const debouncedSearch = useDebouncedValue(searchTerm);
129
+ const selectedDescriptor = descriptors.find(
130
+ (descriptor) =>
131
+ selectedTarget && sameTarget(descriptor.target, selectedTarget),
132
+ );
133
+ const previews = useStorageEntryPreviews({
134
+ client,
135
+ target: selectedTarget,
136
+ search: debouncedSearch,
137
+ keySortDirection,
138
+ });
139
+ const fullEntry = useStorageFullEntry({
140
+ client,
141
+ target: selectedTarget,
142
+ key: interaction?.key,
143
+ enabled: interaction !== null,
144
+ });
145
+ const rows = flattenStorageEntryPreviewPages(previews.data);
146
+ const supportedTypes = selectedDescriptor?.capabilities.supportedTypes ?? [];
147
+ const sidebarGroups = useMemo(() => {
148
+ const storages = new Map(
149
+ descriptors.map((descriptor) => [
150
+ getStorageViewId(descriptor.target),
151
+ descriptor,
152
+ ]),
153
+ );
154
+ return buildStorageSidebarGroups(storages);
155
+ }, [descriptors]);
95
156
 
96
- useEffect(() => {
97
- if (!client) {
98
- return;
99
- }
100
-
101
- const snapshotSubscription = client.onMessage(
102
- 'snapshot',
103
- (event: StorageSnapshotEvent) => {
104
- const viewId = getStorageViewId(event.target);
105
- setSnapshots((previous) => {
106
- const next = new Map(previous);
107
- next.set(viewId, {
108
- target: event.target,
109
- adapterName: event.adapterName,
110
- storageName: event.storageName,
111
- capabilities: event.capabilities,
112
- blacklist: event.blacklist
113
- ? new RegExp(event.blacklist.source, event.blacklist.flags)
114
- : undefined,
115
- entries: event.entries,
116
- });
157
+ const closeInteraction = () => {
158
+ fullEntry.reset();
159
+ setInteraction(null);
160
+ };
117
161
 
118
- if (previous.size === 0 && !selectedStorageViewId) {
119
- setSelectedStorageViewId(viewId);
120
- }
162
+ const refreshSelectedStorage = async () => {
163
+ if (!selectedTarget) return;
164
+ closeInteraction();
165
+ setVirtualListVersion((version) => version + 1);
166
+ await resetSelectedStorageQueries(queryClient, selectedTarget);
167
+ };
121
168
 
122
- return next;
123
- });
169
+ useEffect(() => {
170
+ if (!client) return;
124
171
 
125
- if (viewId === selectedStorageViewId) {
126
- setLoading(false);
127
- }
172
+ const descriptorsSubscription = client.onMessage(
173
+ 'storage-descriptors',
174
+ (event: StorageDiscoverStoragesResponseEvent) => {
175
+ if (event.requestId !== `discovery-${discoveryRequestIdRef.current}`)
176
+ return;
177
+ setDescriptors(event.storages);
178
+ setSelectedTarget((previous) =>
179
+ previous &&
180
+ event.storages.some((descriptor) =>
181
+ sameTarget(descriptor.target, previous),
182
+ )
183
+ ? previous
184
+ : (event.storages[0]?.target ?? null),
185
+ );
128
186
  },
129
187
  );
130
-
131
- const setEntrySubscription = client.onMessage(
132
- 'set-entry',
133
- (event: StorageSetEntryEvent) => {
134
- const viewId = getStorageViewId(event.target);
135
- setSnapshots((previous) => {
136
- const next = new Map(previous);
137
- const current = next.get(viewId);
138
-
139
- if (!current) {
140
- return previous;
141
- }
142
-
143
- const existingIndex = current.entries.findIndex(
144
- (entry) => entry.key === event.entry.key,
145
- );
146
-
147
- const entries =
148
- existingIndex >= 0
149
- ? current.entries.map((entry) =>
150
- entry.key === event.entry.key ? event.entry : entry,
151
- )
152
- : [...current.entries, event.entry];
153
-
154
- next.set(viewId, {
155
- ...current,
156
- entries,
157
- });
158
-
159
- return next;
160
- });
161
-
162
- setImportFlight((previous) => {
163
- if (!previous || previous.phase !== 'importing') return previous;
164
- if (!sameTarget(event.target, previous.target)) return previous;
165
- return { ...previous, written: previous.written + 1 };
166
- });
188
+ const importProgressSubscription = client.onMessage(
189
+ 'import-progress',
190
+ (event: StorageImportProgressEvent) => {
191
+ setImportFlight((previous) =>
192
+ previous?.phase === 'importing' &&
193
+ previous.requestId === event.requestId &&
194
+ sameTarget(previous.target, event.target)
195
+ ? { ...previous, written: event.written, total: event.total }
196
+ : previous,
197
+ );
167
198
  },
168
199
  );
169
-
170
200
  const importResultSubscription = client.onMessage(
171
201
  'import-result',
172
202
  (event: StorageImportResultEvent) => {
203
+ if (
204
+ activeImportRequestIdRef.current === event.requestId &&
205
+ selectedTargetRef.current &&
206
+ sameTarget(event.target, selectedTargetRef.current)
207
+ ) {
208
+ activeImportRequestIdRef.current = null;
209
+ closeInteraction();
210
+ setVirtualListVersion((version) => version + 1);
211
+ void resetSelectedStorageQueries(queryClient, event.target);
212
+ }
173
213
  setImportFlight((previous) => {
174
- if (!previous || previous.phase !== 'importing') return previous;
175
- if (!sameTarget(event.target, previous.target)) return previous;
176
- if (event.ok) {
177
- return { phase: 'result', ok: true, written: event.written };
178
- }
179
- return {
180
- phase: 'result',
181
- ok: false,
182
- written: event.written,
183
- total: event.total,
184
- failedKey: event.failedKey,
185
- error: event.error ?? 'Unknown error',
186
- };
214
+ if (
215
+ previous?.phase !== 'importing' ||
216
+ previous.requestId !== event.requestId ||
217
+ !sameTarget(previous.target, event.target)
218
+ )
219
+ return previous;
220
+ return event.ok
221
+ ? { phase: 'result', ok: true, written: event.written }
222
+ : {
223
+ phase: 'result',
224
+ ok: false,
225
+ written: event.written,
226
+ total: event.total,
227
+ failedKey: event.failedKey,
228
+ error: event.error ?? 'Unknown error',
229
+ };
187
230
  });
188
231
  },
189
232
  );
233
+ const invalidationSubscription = client.onMessage(
234
+ 'storage-invalidated',
235
+ (event: StorageInvalidatedEvent) => {
236
+ void dropStorageFullEntries(queryClient, event.target, event.key);
237
+ if (event.operation === 'purge') {
238
+ setDescriptors((current) =>
239
+ current.map((descriptor) =>
240
+ sameTarget(descriptor.target, event.target)
241
+ ? { ...descriptor, entryCount: 0 }
242
+ : descriptor,
243
+ ),
244
+ );
245
+ }
246
+ if (
247
+ selectedTargetRef.current &&
248
+ sameTarget(event.target, selectedTargetRef.current)
249
+ ) {
250
+ setInteraction((current) =>
251
+ current && (event.key == null || current.key === event.key)
252
+ ? null
253
+ : current,
254
+ );
255
+ }
190
256
 
191
- const deleteEntrySubscription = client.onMessage(
192
- 'delete-entry',
193
- (event: StorageDeleteEntryEvent) => {
194
- const viewId = getStorageViewId(event.target);
195
-
196
- setSnapshots((previous) => {
197
- const next = new Map(previous);
198
- const current = next.get(viewId);
199
-
200
- if (!current) {
201
- return previous;
257
+ const targetId = getStorageViewId(event.target);
258
+ if (pendingInvalidationsRef.current.has(targetId)) return;
259
+ pendingInvalidationsRef.current.add(targetId);
260
+ queueMicrotask(() => {
261
+ pendingInvalidationsRef.current.delete(targetId);
262
+ if (
263
+ selectedTargetRef.current &&
264
+ sameTarget(event.target, selectedTargetRef.current)
265
+ ) {
266
+ setVirtualListVersion((version) => version + 1);
202
267
  }
203
-
204
- next.set(viewId, {
205
- ...current,
206
- entries: current.entries.filter((entry) => entry.key !== event.key),
207
- });
208
-
209
- return next;
268
+ void invalidateStorageEntryPreviews(queryClient, event.target);
210
269
  });
211
270
  },
212
271
  );
213
272
 
214
- client.send('get-snapshot', {
215
- type: 'get-snapshot',
216
- target: 'all',
273
+ discoveryRequestIdRef.current += 1;
274
+ client.send('discover-storages', {
275
+ type: 'discover-storages',
276
+ requestId: `discovery-${discoveryRequestIdRef.current}`,
217
277
  });
218
-
219
278
  return () => {
220
- snapshotSubscription.remove();
221
- setEntrySubscription.remove();
222
- deleteEntrySubscription.remove();
279
+ descriptorsSubscription.remove();
280
+ importProgressSubscription.remove();
223
281
  importResultSubscription.remove();
282
+ invalidationSubscription.remove();
224
283
  };
225
- }, [client, selectedStorageViewId]);
284
+ }, [client, queryClient]);
226
285
 
227
286
  useEffect(() => {
228
- if (!client || !selectedStorageViewId) {
229
- return;
230
- }
231
-
232
- const selectedSnapshot = snapshots.get(selectedStorageViewId);
233
-
234
- if (selectedSnapshot) {
235
- setLoading(false);
236
- return;
237
- }
238
-
239
- const separatorIndex = selectedStorageViewId.indexOf(':');
240
- if (separatorIndex < 0) {
241
- console.warn(
242
- `[Rozenite] Storage Plugin: Invalid storage view id "${selectedStorageViewId}".`,
243
- );
244
- setLoading(false);
245
- return;
246
- }
247
-
248
- const adapterId = selectedStorageViewId.slice(0, separatorIndex);
249
- const storageId = selectedStorageViewId.slice(separatorIndex + 1);
250
-
251
- setLoading(true);
252
- client.send('get-snapshot', {
253
- type: 'get-snapshot',
254
- target: {
255
- adapterId,
256
- storageId,
257
- },
258
- });
259
- }, [client, selectedStorageViewId, snapshots]);
260
-
261
- const selectedStorage = selectedStorageViewId
262
- ? (snapshots.get(selectedStorageViewId) ?? null)
263
- : null;
264
-
265
- const entries = selectedStorage?.entries ?? [];
266
-
267
- const filteredEntries = useMemo(
268
- () =>
269
- entries.filter((entry) =>
270
- entry.key.toLowerCase().includes(searchTerm.toLowerCase()),
271
- ),
272
- [entries, searchTerm],
287
+ selectedTargetRef.current = selectedTarget;
288
+ exportAbortControllerRef.current?.abort();
289
+ exportAbortControllerRef.current = null;
290
+ importPreviewAbortControllerRef.current?.abort();
291
+ importPreviewAbortControllerRef.current = null;
292
+ activeImportRequestIdRef.current = null;
293
+ setExportState({ status: 'idle' });
294
+ setDeleteKey(null);
295
+ setShowPurgeDialog(false);
296
+ setInteraction(null);
297
+ setImportFlight(null);
298
+ }, [selectedTarget]);
299
+
300
+ useEffect(
301
+ () => () => {
302
+ exportAbortControllerRef.current?.abort();
303
+ importPreviewAbortControllerRef.current?.abort();
304
+ },
305
+ [],
273
306
  );
274
307
 
275
- const supportedTypes = selectedStorage?.capabilities.supportedTypes ?? [];
276
-
277
- const updateEntriesForSelectedStorage = (
278
- mutate: (entries: StorageEntry[]) => StorageEntry[],
279
- ) => {
280
- if (!selectedStorageViewId) {
281
- return;
282
- }
283
-
284
- setSnapshots((previous) => {
285
- const next = new Map(previous);
286
- const current = next.get(selectedStorageViewId);
287
-
288
- if (!current) {
289
- return previous;
290
- }
291
-
292
- next.set(selectedStorageViewId, {
293
- ...current,
294
- entries: mutate(current.entries),
295
- });
296
-
297
- return next;
298
- });
308
+ const mutateSelectedStorage = (operation: () => void) => {
309
+ operation();
299
310
  };
300
311
 
301
312
  const handleValueChange = (key: string, newValue: StorageEntryValue) => {
302
- if (!client || !selectedStorage) {
313
+ if (
314
+ !client ||
315
+ !selectedTarget ||
316
+ !supportedTypes.includes(getEntryTypeFromValue(newValue))
317
+ )
303
318
  return;
304
- }
305
-
306
319
  const type = getEntryTypeFromValue(newValue);
307
-
308
- if (!selectedStorage.capabilities.supportedTypes.includes(type)) {
309
- return;
310
- }
311
-
312
- let updatedEntry: StorageEntry;
313
- if (type === 'string') {
314
- updatedEntry = { key, type: 'string', value: newValue as string };
315
- } else if (type === 'number') {
316
- updatedEntry = { key, type: 'number', value: newValue as number };
317
- } else if (type === 'boolean') {
318
- updatedEntry = { key, type: 'boolean', value: newValue as boolean };
319
- } else {
320
- updatedEntry = { key, type: 'buffer', value: newValue as number[] };
321
- }
322
-
323
- client.send('set-entry', {
324
- type: 'set-entry',
325
- target: selectedStorage.target,
326
- entry: updatedEntry,
327
- });
328
-
329
- updateEntriesForSelectedStorage((currentEntries) =>
330
- currentEntries.map((entry) => (entry.key === key ? updatedEntry : entry)),
320
+ const entry =
321
+ type === 'string'
322
+ ? { key, type, value: newValue as string }
323
+ : type === 'number'
324
+ ? { key, type, value: newValue as number }
325
+ : type === 'boolean'
326
+ ? { key, type, value: newValue as boolean }
327
+ : { key, type, value: newValue as number[] };
328
+ mutateSelectedStorage(() =>
329
+ client.send('set-entry', {
330
+ type: 'set-entry',
331
+ target: selectedTarget,
332
+ entry,
333
+ }),
331
334
  );
332
335
  };
333
336
 
334
- const handleDeleteEntry = (key: string) => {
335
- if (!client || !selectedStorage) {
336
- return;
337
- }
338
-
339
- client.send('delete-entry', {
340
- type: 'delete-entry',
341
- target: selectedStorage.target,
342
- key,
343
- });
344
-
345
- updateEntriesForSelectedStorage((currentEntries) =>
346
- currentEntries.filter((entry) => entry.key !== key),
337
+ const handleAddEntry = (entry: StorageEntry) => {
338
+ if (!client || !selectedTarget) return;
339
+ mutateSelectedStorage(() =>
340
+ client.send('set-entry', {
341
+ type: 'set-entry',
342
+ target: selectedTarget,
343
+ entry,
344
+ }),
347
345
  );
348
346
  };
349
347
 
350
- const handleAddEntry = (entry: StorageEntry) => {
351
- if (!client || !selectedStorage) {
352
- return;
353
- }
348
+ const handleDeleteEntry = () => {
349
+ if (!client || !selectedTarget || !deleteKey) return;
350
+ const key = deleteKey;
351
+ setDeleteKey(null);
352
+ mutateSelectedStorage(() =>
353
+ client.send('delete-entry', {
354
+ type: 'delete-entry',
355
+ target: selectedTarget,
356
+ key,
357
+ }),
358
+ );
359
+ };
354
360
 
355
- client.send('set-entry', {
356
- type: 'set-entry',
357
- target: selectedStorage.target,
358
- entry,
361
+ const handlePurgeStorage = () => {
362
+ if (!client || !selectedTarget) return;
363
+ setShowPurgeDialog(false);
364
+ client.send('purge-storage', {
365
+ type: 'purge-storage',
366
+ target: selectedTarget,
359
367
  });
360
-
361
- updateEntriesForSelectedStorage((currentEntries) => [
362
- ...currentEntries,
363
- entry,
364
- ]);
365
368
  };
366
369
 
367
370
  const showAlert = (title: string, message: string) =>
368
- setAlertState({ isOpen: true, title, message });
371
+ setAlertState({ title, message });
369
372
 
370
373
  const handleImportClick = () => {
371
- if (!fileInputRef.current) return;
372
- fileInputRef.current.value = '';
373
- fileInputRef.current.click();
374
+ if (fileInputRef.current) {
375
+ fileInputRef.current.value = '';
376
+ fileInputRef.current.click();
377
+ }
374
378
  };
375
379
 
376
- const handleFileChange = async (
377
- event: React.ChangeEvent<HTMLInputElement>,
378
- ) => {
380
+ const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
379
381
  const file = event.target.files?.[0];
380
- if (!file || !selectedStorage) {
381
- return;
382
- }
383
-
382
+ if (!file || !client || !selectedTarget) return;
384
383
  let raw: unknown;
385
384
  try {
386
- const text = await file.text();
387
- raw = JSON.parse(text);
388
- } catch (parseError) {
385
+ raw = JSON.parse(await file.text());
386
+ } catch (error) {
389
387
  showAlert(
390
388
  'Could not read file',
391
- parseError instanceof Error ? parseError.message : String(parseError),
389
+ error instanceof Error ? error.message : String(error),
392
390
  );
393
391
  return;
394
392
  }
395
-
396
393
  const parsed = parseSnapshot(raw);
397
394
  if (!parsed.ok) {
398
395
  showAlert(
@@ -401,242 +398,473 @@ export default function StoragePanel() {
401
398
  );
402
399
  return;
403
400
  }
404
-
405
- const preview = computePreview(parsed.snapshot, {
406
- target: selectedStorage.target,
407
- capabilities: selectedStorage.capabilities,
408
- entryKeys: new Set(selectedStorage.entries.map((entry) => entry.key)),
409
- isBlacklisted: selectedStorage.blacklist
410
- ? (key) => selectedStorage.blacklist!.test(key)
411
- : () => false,
412
- });
413
-
414
- const skippedSet = new Set(preview.skippedKeys.map((s) => s.key));
415
- const unsupportedSet = new Set(preview.unsupportedTypes.map((u) => u.key));
416
- const entriesToWrite = parsed.snapshot.entries.filter(
417
- (entry) => !skippedSet.has(entry.key) && !unsupportedSet.has(entry.key),
418
- );
419
-
420
- setImportFlight({
421
- phase: 'preview',
422
- target: selectedStorage.target,
423
- targetLabel: `${selectedStorage.adapterName} / ${selectedStorage.storageName}`,
424
- snapshot: parsed.snapshot,
425
- preview,
426
- entriesToWrite,
427
- });
401
+ importPreviewAbortControllerRef.current?.abort();
402
+ const controller = new AbortController();
403
+ importPreviewAbortControllerRef.current = controller;
404
+ const target = selectedTarget;
405
+ try {
406
+ const response = await client.request({
407
+ requestType: 'preview-import',
408
+ responseType: 'import-preview',
409
+ errorType: 'storage-request-error',
410
+ payload: { type: 'preview-import', target, snapshot: parsed.snapshot },
411
+ signal: controller.signal,
412
+ });
413
+ if (
414
+ controller.signal.aborted ||
415
+ !sameTarget(target, selectedTargetRef.current ?? target)
416
+ )
417
+ return;
418
+ setImportFlight({
419
+ phase: 'preview',
420
+ target,
421
+ targetLabel: selectedDescriptor
422
+ ? `${selectedDescriptor.adapterName} / ${selectedDescriptor.storageName}`
423
+ : 'selected storage',
424
+ snapshot: parsed.snapshot,
425
+ preview: response.preview,
426
+ entriesToWrite: response.preview.acceptedEntryIndexes.map(
427
+ (index) => parsed.snapshot.entries[index],
428
+ ),
429
+ });
430
+ } catch {
431
+ if (
432
+ !controller.signal.aborted &&
433
+ sameTarget(target, selectedTargetRef.current ?? target)
434
+ ) {
435
+ showAlert(
436
+ 'Could not preview import',
437
+ 'Could not inspect the selected storage. Please try again.',
438
+ );
439
+ }
440
+ } finally {
441
+ if (importPreviewAbortControllerRef.current === controller)
442
+ importPreviewAbortControllerRef.current = null;
443
+ }
428
444
  };
429
445
 
430
446
  const handleApplyImport = () => {
431
- if (!client) return;
432
- if (!importFlight || importFlight.phase !== 'preview') return;
433
-
447
+ if (!client || importFlight?.phase !== 'preview') return;
448
+ const requestId = `import-${++importRequestIdRef.current}`;
449
+ activeImportRequestIdRef.current = requestId;
434
450
  client.send('import-entries', {
435
451
  type: 'import-entries',
452
+ requestId,
436
453
  target: importFlight.target,
437
454
  entries: importFlight.entriesToWrite,
438
455
  });
439
-
440
456
  setImportFlight({
441
457
  phase: 'importing',
458
+ requestId,
442
459
  target: importFlight.target,
443
460
  total: importFlight.entriesToWrite.length,
444
461
  written: 0,
445
462
  });
446
463
  };
447
464
 
448
- const handleCloseImport = () => setImportFlight(null);
449
-
450
- const handleExport = () => {
451
- if (!selectedStorage) return;
452
- const snapshot = buildSnapshot({
453
- target: selectedStorage.target,
454
- adapterName: selectedStorage.adapterName,
455
- storageName: selectedStorage.storageName,
456
- capabilities: selectedStorage.capabilities,
457
- entries: selectedStorage.entries,
458
- });
459
- downloadJson(snapshot, buildExportFilename(selectedStorage.target));
465
+ const handleExport = async () => {
466
+ if (!client || !selectedTarget || exportAbortControllerRef.current) return;
467
+ const target = selectedTarget;
468
+ const controller = new AbortController();
469
+ exportAbortControllerRef.current = controller;
470
+ setExportState({ status: 'loading' });
471
+ try {
472
+ const response = await client.request({
473
+ requestType: 'export-snapshot',
474
+ responseType: 'export-snapshot-result',
475
+ errorType: 'storage-request-error',
476
+ payload: { type: 'export-snapshot', target },
477
+ signal: controller.signal,
478
+ });
479
+ if (
480
+ !controller.signal.aborted &&
481
+ sameTarget(target, selectedTargetRef.current ?? target)
482
+ ) {
483
+ downloadJson(response.snapshot, buildExportFilename(target));
484
+ setExportState({ status: 'idle' });
485
+ }
486
+ } catch {
487
+ if (
488
+ !controller.signal.aborted &&
489
+ sameTarget(target, selectedTargetRef.current ?? target)
490
+ ) {
491
+ setExportState({
492
+ status: 'error',
493
+ message: 'Could not export the selected storage. Please try again.',
494
+ });
495
+ }
496
+ } finally {
497
+ if (exportAbortControllerRef.current === controller)
498
+ exportAbortControllerRef.current = null;
499
+ }
460
500
  };
461
501
 
462
- const storageOptions = [...snapshots.entries()].map(([viewId, snapshot]) => ({
463
- viewId,
464
- label: `${snapshot.adapterName} / ${snapshot.storageName}`,
465
- }));
466
-
467
- return (
468
- <div className="h-screen bg-gray-900 text-gray-100 flex flex-col">
469
- <div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
470
- <div className="flex items-center gap-2">
471
- <span className="text-sm font-medium text-gray-200">Storage</span>
472
- </div>
473
- <div className="flex-1" />
474
- <div className="flex items-center gap-2">
475
- <label htmlFor="storage-select" className="text-xs text-gray-400">
476
- Storage:
477
- </label>
478
- <select
479
- id="storage-select"
480
- value={selectedStorageViewId ?? ''}
481
- onChange={(event) => setSelectedStorageViewId(event.target.value)}
482
- disabled={snapshots.size === 0}
483
- className="h-8 px-2 text-xs bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
502
+ const columns = useMemo<ColumnDef<StorageEntryPreview>[]>(
503
+ () => [
504
+ {
505
+ id: 'key',
506
+ accessorKey: 'key',
507
+ header: 'Key',
508
+ enableSorting: true,
509
+ cell: ({ row }) => (
510
+ <span className="font-mono text-sm text-foreground">
511
+ {row.original.key}
512
+ </span>
513
+ ),
514
+ },
515
+ {
516
+ id: 'type',
517
+ accessorKey: 'type',
518
+ header: 'Type',
519
+ enableSorting: false,
520
+ cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
521
+ },
522
+ {
523
+ id: 'preview',
524
+ accessorKey: 'preview',
525
+ header: 'Value',
526
+ enableSorting: false,
527
+ cell: ({ row }) => (
528
+ <div className="min-w-0">
529
+ <span className="break-all font-mono text-sm text-foreground">
530
+ {row.original.preview}
531
+ </span>
532
+ <span
533
+ className="ml-2 text-xs text-muted-foreground"
534
+ title={
535
+ row.original.isTruncated
536
+ ? `Full value size: ${row.original.valueSize}`
537
+ : undefined
538
+ }
539
+ >
540
+ {row.original.isTruncated
541
+ ? `truncated · ${row.original.valueSize}`
542
+ : row.original.valueSize}
543
+ </span>
544
+ </div>
545
+ ),
546
+ },
547
+ {
548
+ id: 'actions',
549
+ header: '',
550
+ enableSorting: false,
551
+ cell: ({ row }) => (
552
+ <div
553
+ className="flex items-center gap-1"
554
+ onClick={(event) => event.stopPropagation()}
484
555
  >
485
- {snapshots.size === 0 ? (
486
- <option>No storages found</option>
487
- ) : (
488
- storageOptions.map((option) => (
489
- <option key={option.viewId} value={option.viewId}>
490
- {option.label}
491
- </option>
492
- ))
493
- )}
494
- </select>
495
- </div>
496
- </div>
497
-
498
- <div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
499
- <button
500
- onClick={() => setShowAddDialog(true)}
501
- disabled={!selectedStorage}
502
- className="flex items-center gap-1 px-3 h-8 text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
503
- title="Add new entry"
504
- >
505
- <Plus className="h-3 w-3" />
506
- Add Entry
507
- </button>
508
- <button
509
- onClick={handleImportClick}
510
- disabled={!selectedStorage}
511
- className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
512
- title="Import entries from a JSON snapshot"
513
- >
514
- <Upload className="h-3 w-3" />
515
- Import
516
- </button>
517
- <button
518
- onClick={handleExport}
519
- disabled={!selectedStorage || entries.length === 0}
520
- className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
521
- title="Export entries to a JSON snapshot"
522
- >
523
- <Download className="h-3 w-3" />
524
- Export
525
- </button>
526
- <input
527
- ref={fileInputRef}
528
- type="file"
529
- accept="application/json,.json"
530
- className="hidden"
531
- onChange={handleFileChange}
532
- />
533
- <div className="flex-1">
534
- <div className="relative">
535
- <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
536
- <input
537
- type="text"
538
- placeholder="Search keys..."
539
- value={searchTerm}
540
- onChange={(event) => setSearchTerm(event.target.value)}
541
- className="h-8 w-full pl-8 pr-3 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"
542
- />
556
+ <Button
557
+ variant="ghost"
558
+ size="icon"
559
+ onClick={() =>
560
+ setInteraction({ key: row.original.key, mode: 'edit' })
561
+ }
562
+ aria-label={`Edit value for ${row.original.key}`}
563
+ >
564
+ <Edit3 className="h-3.5 w-3.5" />
565
+ </Button>
566
+ <Button
567
+ variant="ghost"
568
+ size="icon"
569
+ className="text-muted-foreground hover:text-destructive"
570
+ onClick={() => setDeleteKey(row.original.key)}
571
+ aria-label={`Delete entry ${row.original.key}`}
572
+ >
573
+ <Trash2 className="h-3.5 w-3.5" />
574
+ </Button>
543
575
  </div>
544
- </div>
545
- <div className="flex items-center gap-2 text-xs text-gray-400">
546
- {filteredEntries.length} of {entries.length} entries
547
- </div>
548
- </div>
576
+ ),
577
+ },
578
+ ],
579
+ [],
580
+ );
581
+ const sorting: SortingState = [
582
+ { id: 'key', desc: keySortDirection === 'descending' },
583
+ ];
584
+ const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
585
+ const next = typeof updater === 'function' ? updater(sorting) : updater;
586
+ const keySort = next.find((item) => item.id === 'key');
587
+ setKeySortDirection(keySort?.desc ? 'descending' : 'ascending');
588
+ };
589
+ const fetchNextPage = () => {
590
+ if (
591
+ !previews.hasNextPage ||
592
+ previews.isFetching ||
593
+ previews.isFetchingNextPage ||
594
+ isFetchingNextPageRef.current
595
+ )
596
+ return;
597
+ isFetchingNextPageRef.current = true;
598
+ void previews.fetchNextPage().finally(() => {
599
+ isFetchingNextPageRef.current = false;
600
+ });
601
+ };
602
+ const fetchPreviousPage = () => {
603
+ if (
604
+ !previews.hasPreviousPage ||
605
+ previews.isFetching ||
606
+ previews.isFetchingPreviousPage ||
607
+ isFetchingPreviousPageRef.current
608
+ )
609
+ return;
610
+ isFetchingPreviousPageRef.current = true;
611
+ void previews.fetchPreviousPage().finally(() => {
612
+ isFetchingPreviousPageRef.current = false;
613
+ });
614
+ };
615
+ const selectedStorageViewId = selectedTarget
616
+ ? getStorageViewId(selectedTarget)
617
+ : '';
549
618
 
550
- <main className="flex flex-1 min-h-0 overflow-auto">
551
- {selectedStorage ? (
552
- filteredEntries.length === 0 ? (
553
- <div className="flex flex-col items-center justify-center h-full text-center w-full">
554
- <h3 className="text-lg font-semibold text-gray-200 mb-2">
555
- No entries found
556
- </h3>
557
- <p className="text-gray-400 text-sm">
558
- {searchTerm
559
- ? 'Try adjusting your search terms'
560
- : 'This storage appears to be empty'}
561
- </p>
619
+ return (
620
+ <PluginShell>
621
+ <PluginShell.Body>
622
+ <Split direction="horizontal" autoSaveId="storage">
623
+ <Split.Pane defaultSize={22} minSize={15} maxSize={40}>
624
+ <Sidebar className="w-full border-r-0">
625
+ {sidebarGroups.length === 0 ? (
626
+ <div className="flex flex-1 items-center justify-center p-4 text-center text-xs text-sidebar-foreground/60">
627
+ Waiting for storages…
628
+ </div>
629
+ ) : (
630
+ sidebarGroups.map((group) => (
631
+ <Sidebar.Group
632
+ key={group.adapterId}
633
+ label={group.adapterName}
634
+ >
635
+ {group.items.map((item) => (
636
+ <Sidebar.Item
637
+ key={item.viewId}
638
+ selected={item.viewId === selectedStorageViewId}
639
+ trailing={
640
+ <Badge variant="secondary">{item.entryCount}</Badge>
641
+ }
642
+ onClick={() => {
643
+ const descriptor = descriptors.find(
644
+ (candidate) =>
645
+ getStorageViewId(candidate.target) ===
646
+ item.viewId,
647
+ );
648
+ if (descriptor) setSelectedTarget(descriptor.target);
649
+ }}
650
+ >
651
+ {item.storageName}
652
+ </Sidebar.Item>
653
+ ))}
654
+ </Sidebar.Group>
655
+ ))
656
+ )}
657
+ </Sidebar>
658
+ </Split.Pane>
659
+ <Split.Handle />
660
+ <Split.Pane>
661
+ <div className="flex h-full min-h-0 flex-col">
662
+ <Toolbar>
663
+ <Toolbar.Group>
664
+ <Toolbar.Button
665
+ onClick={() => setShowAddDialog(true)}
666
+ disabled={!selectedDescriptor}
667
+ aria-label="Add entry"
668
+ title="Add entry"
669
+ className="w-7 px-0"
670
+ >
671
+ <Plus className="h-3.5 w-3.5" />
672
+ </Toolbar.Button>
673
+ <Toolbar.Button
674
+ onClick={() => setShowPurgeDialog(true)}
675
+ disabled={!client || !selectedTarget || previews.isFetching}
676
+ aria-label="Purge storage"
677
+ title="Purge storage"
678
+ className="w-7 px-0 text-muted-foreground hover:text-destructive"
679
+ >
680
+ <Trash2 className="h-3.5 w-3.5" />
681
+ </Toolbar.Button>
682
+ </Toolbar.Group>
683
+ <Toolbar.Separator />
684
+ <Toolbar.Group>
685
+ <Toolbar.Button
686
+ onClick={() => void refreshSelectedStorage()}
687
+ disabled={!selectedTarget || previews.isFetching}
688
+ aria-label="Refresh storage"
689
+ title="Refresh storage"
690
+ className="w-7 px-0"
691
+ >
692
+ <RefreshCw className="h-3.5 w-3.5" />
693
+ </Toolbar.Button>
694
+ </Toolbar.Group>
695
+ <Toolbar.Separator />
696
+ <Toolbar.Group>
697
+ <Toolbar.Button
698
+ onClick={handleImportClick}
699
+ disabled={!selectedDescriptor}
700
+ aria-label="Import storage"
701
+ title="Import storage"
702
+ className="w-7 px-0"
703
+ >
704
+ <Upload className="h-3.5 w-3.5" />
705
+ </Toolbar.Button>
706
+ <Toolbar.Button
707
+ onClick={handleExport}
708
+ disabled={
709
+ !client ||
710
+ !selectedTarget ||
711
+ exportState.status === 'loading'
712
+ }
713
+ aria-label={
714
+ exportState.status === 'loading'
715
+ ? 'Exporting storage'
716
+ : 'Export storage'
717
+ }
718
+ title={
719
+ exportState.status === 'loading'
720
+ ? 'Exporting storage'
721
+ : 'Export storage'
722
+ }
723
+ className="w-7 px-0"
724
+ >
725
+ <Download className="h-3.5 w-3.5" />
726
+ </Toolbar.Button>
727
+ </Toolbar.Group>
728
+ <Toolbar.Separator />
729
+ <div className="min-w-40 flex-1">
730
+ <SearchField
731
+ placeholder="Search keys…"
732
+ value={searchTerm}
733
+ onChange={(event) => setSearchTerm(event.target.value)}
734
+ onClear={() => setSearchTerm('')}
735
+ disabled={!selectedDescriptor}
736
+ />
737
+ </div>
738
+ {exportState.status === 'error' ? (
739
+ <span role="alert" className="text-xs text-destructive">
740
+ {exportState.message}
741
+ </span>
742
+ ) : null}
743
+ <input
744
+ ref={fileInputRef}
745
+ type="file"
746
+ accept="application/json,.json"
747
+ className="hidden"
748
+ onChange={handleFileChange}
749
+ />
750
+ </Toolbar>
751
+ <div className="min-h-0 flex-1 overflow-auto">
752
+ {selectedDescriptor ? (
753
+ <VirtualizedDataTable
754
+ key={virtualListVersion}
755
+ ariaLabel="Storage entries"
756
+ columns={columns}
757
+ data={rows}
758
+ getRowId={(entry) => entry.key}
759
+ getRowTextValue={(entry) => entry.key}
760
+ loading={previews.isLoading}
761
+ emptyMessage={
762
+ debouncedSearch
763
+ ? 'No entries match your search.'
764
+ : 'This storage is empty.'
765
+ }
766
+ manualSorting
767
+ onEndReached={fetchNextPage}
768
+ onRowClick={(entry) =>
769
+ setInteraction({ key: entry.key, mode: 'detail' })
770
+ }
771
+ onSortingChange={handleSortingChange}
772
+ onStartReached={fetchPreviousPage}
773
+ scrollClassName="h-full w-full overflow-auto"
774
+ style={{ height: '100%' }}
775
+ sorting={sorting}
776
+ />
777
+ ) : (
778
+ <EmptyState
779
+ icon={Database}
780
+ title="No storage selected"
781
+ description="Choose a storage from the sidebar to inspect its entries."
782
+ />
783
+ )}
784
+ </div>
562
785
  </div>
563
- ) : (
564
- <EditableTable
565
- data={filteredEntries}
566
- supportedTypes={supportedTypes}
567
- onValueChange={handleValueChange}
568
- onDeleteEntry={handleDeleteEntry}
569
- onRowClick={(entry) => {
570
- setSelectedEntry(entry);
571
- setShowDetailDialog(true);
572
- }}
573
- loading={loading}
574
- />
575
- )
576
- ) : (
577
- <div className="flex flex-col items-center justify-center h-full text-center w-full">
578
- <h2 className="text-xl font-semibold text-gray-200 mb-2">
579
- Welcome to Storage Inspector
580
- </h2>
581
- <p className="text-gray-400 text-sm">
582
- Select a storage from the dropdown above to inspect data
583
- </p>
584
- </div>
585
- )}
586
- </main>
587
-
786
+ </Split.Pane>
787
+ </Split>
788
+ </PluginShell.Body>
588
789
  <AddEntryDialog
589
790
  isOpen={showAddDialog}
590
791
  onClose={() => setShowAddDialog(false)}
591
792
  onAddEntry={handleAddEntry}
592
- existingKeys={entries.map((entry) => entry.key)}
793
+ existingKeys={rows.map((entry) => entry.key)}
593
794
  supportedTypes={supportedTypes}
594
795
  />
595
-
596
796
  <EntryDetailDialog
597
- isOpen={showDetailDialog}
598
- onClose={() => {
599
- setShowDetailDialog(false);
600
- setSelectedEntry(null);
601
- }}
602
- onEdit={(entry) => {
603
- setShowDetailDialog(false);
604
- setEditingEntry(entry);
605
- setShowEditDialog(true);
797
+ open={interaction?.mode === 'detail' && fullEntry.entry != null}
798
+ onOpenChange={(open) => {
799
+ if (!open) closeInteraction();
606
800
  }}
607
- entry={selectedEntry}
801
+ onEdit={() =>
802
+ setInteraction((current) =>
803
+ current ? { ...current, mode: 'edit' } : current,
804
+ )
805
+ }
806
+ entry={fullEntry.entry ?? null}
608
807
  />
609
-
610
808
  <EditEntryDialog
611
- isOpen={showEditDialog}
612
- onClose={() => {
613
- setShowEditDialog(false);
614
- setEditingEntry(null);
615
- }}
616
- onEditEntry={(key, newValue) => {
617
- handleValueChange(key, newValue);
618
- setShowEditDialog(false);
619
- setEditingEntry(null);
620
- }}
809
+ isOpen={interaction?.mode === 'edit' && fullEntry.entry != null}
810
+ onClose={closeInteraction}
811
+ onEditEntry={handleValueChange}
812
+ entry={fullEntry.entry ?? null}
621
813
  supportedTypes={supportedTypes}
622
- entry={editingEntry}
623
814
  />
624
-
625
815
  <ImportDialog
626
816
  state={importFlight}
627
817
  onApply={handleApplyImport}
628
- onCancel={handleCloseImport}
629
- onClose={handleCloseImport}
818
+ onCancel={() => setImportFlight(null)}
819
+ onClose={() => setImportFlight(null)}
820
+ />
821
+ <ConfirmDialog
822
+ open={deleteKey !== null}
823
+ onOpenChange={(open) => {
824
+ if (!open) setDeleteKey(null);
825
+ }}
826
+ variant="confirm"
827
+ destructive
828
+ title="Delete Entry"
829
+ description={
830
+ deleteKey
831
+ ? `Are you sure you want to delete the entry "${deleteKey}"?`
832
+ : undefined
833
+ }
834
+ confirmLabel="Delete"
835
+ onConfirm={handleDeleteEntry}
836
+ />
837
+ <ConfirmDialog
838
+ open={showPurgeDialog}
839
+ onOpenChange={setShowPurgeDialog}
840
+ variant="confirm"
841
+ destructive
842
+ title="Purge Storage"
843
+ description={
844
+ selectedDescriptor
845
+ ? `Are you sure you want to remove all entries from "${selectedDescriptor.storageName}"? This action cannot be undone.`
846
+ : 'Are you sure you want to remove all entries from this storage? This action cannot be undone.'
847
+ }
848
+ confirmLabel="Purge"
849
+ onConfirm={handlePurgeStorage}
630
850
  />
631
-
632
851
  <ConfirmDialog
633
- isOpen={alertState.isOpen}
634
- onClose={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
635
- onConfirm={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
636
- title={alertState.title}
637
- message={alertState.message}
638
- type="alert"
852
+ open={alertState !== null}
853
+ onOpenChange={(open) => {
854
+ if (!open) setAlertState(null);
855
+ }}
856
+ variant="alert"
857
+ title={alertState?.title ?? ''}
858
+ description={alertState?.message}
639
859
  />
640
- </div>
860
+ </PluginShell>
861
+ );
862
+ }
863
+
864
+ export default function StoragePanel() {
865
+ return (
866
+ <StorageQueryClientProvider>
867
+ <StoragePanelContent />
868
+ </StorageQueryClientProvider>
641
869
  );
642
870
  }