@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.
- package/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
- package/dist/devtools/assets/panel-DBweZnW5.css +1 -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 -66
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +17 -13
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
- package/dist/react-native/index.d.ts +166 -18
- 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 +35 -37
- package/react-native.ts +12 -22
- package/rozenite.config.ts +74 -0
- package/sdk.ts +1 -5
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
- package/src/react-native/__tests__/import.test.ts +174 -11
- package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +140 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
- package/src/react-native/adapters/async-storage.ts +8 -9
- package/src/react-native/adapters/index.ts +2 -8
- package/src/react-native/adapters/mmkv.ts +12 -24
- package/src/react-native/adapters/secure-storage.ts +4 -0
- package/src/react-native/entry-preview-pagination.ts +261 -0
- package/src/react-native/export-snapshot.ts +88 -0
- package/src/react-native/full-entry-request.ts +91 -0
- package/src/react-native/import.ts +179 -16
- package/src/react-native/storage-discovery.ts +92 -0
- package/src/react-native/storage-view.ts +47 -109
- package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
- package/src/react-native/useStorageAgentTools.ts +196 -91
- package/src/shared/__tests__/entry-preview.test.ts +99 -0
- package/src/shared/__tests__/snapshot.test.ts +31 -32
- package/src/shared/agent-tools.ts +33 -38
- package/src/shared/entry-preview.ts +54 -0
- package/src/shared/messaging.ts +140 -21
- package/src/shared/snapshot.ts +12 -18
- package/src/shared/types.ts +21 -4
- package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
- package/src/ui/__tests__/binary.test.ts +31 -33
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
- package/src/ui/__tests__/panel.test.tsx +382 -0
- package/src/ui/__tests__/storage-groups.test.ts +88 -0
- package/src/ui/__tests__/type-conversion.test.ts +8 -24
- package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
- package/src/ui/add-entry-dialog.tsx +66 -117
- package/src/ui/binary-value-editor-state.ts +16 -25
- package/src/ui/binary-value-editor.tsx +59 -47
- package/src/ui/binary.ts +54 -15
- package/src/ui/edit-entry-dialog.tsx +68 -104
- package/src/ui/editor-switcher.tsx +9 -16
- package/src/ui/entry-detail-dialog.tsx +62 -181
- package/src/ui/format-value.tsx +25 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +72 -109
- package/src/ui/large-value-viewer-state.ts +25 -0
- package/src/ui/large-value-viewer.tsx +65 -0
- package/src/ui/panel.tsx +649 -509
- package/src/ui/query-client.tsx +23 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/type-conversion.ts +2 -5
- package/src/ui/typed-value-editor.tsx +15 -20
- package/src/ui/use-storage-entries.ts +192 -0
- package/src/ui/utils.ts +1 -4
- 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,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 {
|
|
3
|
-
|
|
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
|
-
|
|
19
|
+
StorageDiscoverStoragesResponseEvent,
|
|
6
20
|
StorageEventMap,
|
|
21
|
+
StorageImportProgressEvent,
|
|
7
22
|
StorageImportResultEvent,
|
|
8
|
-
|
|
9
|
-
StorageSnapshotEvent,
|
|
23
|
+
StorageInvalidatedEvent,
|
|
10
24
|
} from '../shared/messaging';
|
|
25
|
+
import { parseSnapshot } from '../shared/snapshot';
|
|
11
26
|
import type {
|
|
12
|
-
|
|
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 {
|
|
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
|
|
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
|
-
};
|
|
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
|
|
52
|
-
|
|
53
|
-
if (typeof value === '
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
64
|
+
const useDebouncedValue = (value: string, delay = 250) => {
|
|
65
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const timeout = window.setTimeout(() => setDebouncedValue(value), delay);
|
|
69
|
+
return () => window.clearTimeout(timeout);
|
|
70
|
+
}, [delay, value]);
|
|
64
71
|
|
|
65
|
-
return
|
|
72
|
+
return debouncedValue;
|
|
66
73
|
};
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
const [
|
|
70
|
-
|
|
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 [
|
|
79
|
-
const [
|
|
80
|
-
const [
|
|
81
|
-
const [
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
const closeInteraction = () => {
|
|
130
|
+
fullEntry.reset();
|
|
131
|
+
setInteraction(null);
|
|
132
|
+
};
|
|
100
133
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!client) return;
|
|
121
143
|
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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 (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
ok:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
deleteEntrySubscription.remove();
|
|
249
|
+
descriptorsSubscription.remove();
|
|
250
|
+
importProgressSubscription.remove();
|
|
223
251
|
importResultSubscription.remove();
|
|
252
|
+
invalidationSubscription.remove();
|
|
253
|
+
deviceReadySubscription.remove();
|
|
224
254
|
};
|
|
225
|
-
}, [client,
|
|
255
|
+
}, [client, queryClient]);
|
|
226
256
|
|
|
227
257
|
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],
|
|
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
|
|
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 || !
|
|
284
|
+
if (!client || !selectedTarget || !supportedTypes.includes(getEntryTypeFromValue(newValue)))
|
|
303
285
|
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
286
|
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)),
|
|
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
|
|
335
|
-
if (!client || !
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
351
|
-
if (!client || !
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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 (
|
|
372
|
-
|
|
373
|
-
|
|
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 || !
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
348
|
+
if (!file || !client || !selectedTarget) return;
|
|
384
349
|
let raw: unknown;
|
|
385
350
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
<
|
|
469
|
-
<
|
|
470
|
-
<
|
|
471
|
-
<
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
709
|
+
existingKeys={rows.map((entry) => entry.key)}
|
|
593
710
|
supportedTypes={supportedTypes}
|
|
594
711
|
/>
|
|
595
|
-
|
|
596
712
|
<EntryDetailDialog
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
717
|
+
onEdit={() =>
|
|
718
|
+
setInteraction((current) => (current ? { ...current, mode: 'edit' } : current))
|
|
719
|
+
}
|
|
720
|
+
entry={fullEntry.entry ?? null}
|
|
608
721
|
/>
|
|
609
|
-
|
|
610
722
|
<EditEntryDialog
|
|
611
|
-
isOpen={
|
|
612
|
-
onClose={
|
|
613
|
-
|
|
614
|
-
|
|
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={
|
|
629
|
-
onClose={
|
|
732
|
+
onCancel={() => setImportFlight(null)}
|
|
733
|
+
onClose={() => setImportFlight(null)}
|
|
630
734
|
/>
|
|
631
|
-
|
|
632
735
|
<ConfirmDialog
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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
|
}
|