@rozenite/storage-plugin 1.13.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
- package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -64
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +22 -14
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
- package/dist/react-native/index.d.ts +154 -18
- package/dist/react-native/index.js +10 -2
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +13 -15
- package/react-native.ts +29 -14
- package/rozenite.config.ts +78 -0
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
- package/src/react-native/__tests__/import.test.ts +172 -10
- package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +153 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
- package/src/react-native/adapters/async-storage.ts +9 -7
- package/src/react-native/adapters/mmkv.ts +5 -0
- package/src/react-native/adapters/secure-storage.ts +9 -1
- package/src/react-native/entry-preview-pagination.ts +277 -0
- package/src/react-native/export-snapshot.ts +94 -0
- package/src/react-native/full-entry-request.ts +96 -0
- package/src/react-native/import.ts +190 -16
- package/src/react-native/storage-discovery.ts +96 -0
- package/src/react-native/storage-view.ts +45 -103
- package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
- package/src/react-native/useStorageAgentTools.ts +207 -86
- package/src/shared/__tests__/entry-preview.test.ts +117 -0
- package/src/shared/__tests__/snapshot.test.ts +21 -0
- package/src/shared/agent-tools.ts +27 -11
- package/src/shared/entry-preview.ts +59 -0
- package/src/shared/messaging.ts +136 -21
- package/src/shared/snapshot.ts +8 -1
- package/src/shared/types.ts +20 -1
- package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
- package/src/ui/__tests__/binary.test.ts +18 -0
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
- package/src/ui/__tests__/panel.test.tsx +409 -0
- package/src/ui/__tests__/storage-groups.test.ts +91 -0
- package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
- package/src/ui/add-entry-dialog.tsx +65 -105
- package/src/ui/binary-value-editor-state.ts +10 -1
- package/src/ui/binary-value-editor.tsx +57 -33
- package/src/ui/binary.ts +56 -4
- package/src/ui/edit-entry-dialog.tsx +71 -99
- package/src/ui/editor-switcher.tsx +7 -5
- package/src/ui/entry-detail-dialog.tsx +69 -179
- package/src/ui/format-value.tsx +32 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +66 -91
- package/src/ui/large-value-viewer-state.ts +32 -0
- package/src/ui/large-value-viewer.tsx +71 -0
- package/src/ui/panel.tsx +724 -496
- package/src/ui/query-client.tsx +34 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/typed-value-editor.tsx +14 -15
- package/src/ui/use-storage-entries.ts +207 -0
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- package/tailwind.config.ts +0 -94
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 {
|
|
3
|
-
|
|
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
|
-
|
|
31
|
+
StorageDiscoverStoragesResponseEvent,
|
|
6
32
|
StorageEventMap,
|
|
33
|
+
StorageImportProgressEvent,
|
|
7
34
|
StorageImportResultEvent,
|
|
8
|
-
|
|
9
|
-
StorageSnapshotEvent,
|
|
35
|
+
StorageInvalidatedEvent,
|
|
10
36
|
} from '../shared/messaging';
|
|
37
|
+
import { parseSnapshot } from '../shared/snapshot';
|
|
11
38
|
import type {
|
|
12
|
-
|
|
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 {
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
78
|
+
const useDebouncedValue = (value: string, delay = 250) => {
|
|
79
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
60
80
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const timeout = window.setTimeout(() => setDebouncedValue(value), delay);
|
|
83
|
+
return () => window.clearTimeout(timeout);
|
|
84
|
+
}, [delay, value]);
|
|
64
85
|
|
|
65
|
-
return
|
|
86
|
+
return debouncedValue;
|
|
66
87
|
};
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
const [
|
|
70
|
-
|
|
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 [
|
|
79
|
-
const [
|
|
80
|
-
const [
|
|
81
|
-
const [
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!client) return;
|
|
124
171
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
ok:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
deleteEntrySubscription.remove();
|
|
279
|
+
descriptorsSubscription.remove();
|
|
280
|
+
importProgressSubscription.remove();
|
|
223
281
|
importResultSubscription.remove();
|
|
282
|
+
invalidationSubscription.remove();
|
|
224
283
|
};
|
|
225
|
-
}, [client,
|
|
284
|
+
}, [client, queryClient]);
|
|
226
285
|
|
|
227
286
|
useEffect(() => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
335
|
-
if (!client || !
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
351
|
-
if (!client || !
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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({
|
|
371
|
+
setAlertState({ title, message });
|
|
369
372
|
|
|
370
373
|
const handleImportClick = () => {
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
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 || !
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
382
|
+
if (!file || !client || !selectedTarget) return;
|
|
384
383
|
let raw: unknown;
|
|
385
384
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
} catch (parseError) {
|
|
385
|
+
raw = JSON.parse(await file.text());
|
|
386
|
+
} catch (error) {
|
|
389
387
|
showAlert(
|
|
390
388
|
'Could not read file',
|
|
391
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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={
|
|
793
|
+
existingKeys={rows.map((entry) => entry.key)}
|
|
593
794
|
supportedTypes={supportedTypes}
|
|
594
795
|
/>
|
|
595
|
-
|
|
596
796
|
<EntryDetailDialog
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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={
|
|
612
|
-
onClose={
|
|
613
|
-
|
|
614
|
-
|
|
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={
|
|
629
|
-
onClose={
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
</
|
|
860
|
+
</PluginShell>
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export default function StoragePanel() {
|
|
865
|
+
return (
|
|
866
|
+
<StorageQueryClientProvider>
|
|
867
|
+
<StoragePanelContent />
|
|
868
|
+
</StorageQueryClientProvider>
|
|
641
869
|
);
|
|
642
870
|
}
|