@rozenite/storage-plugin 1.13.0 → 2.1.0

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