@rozenite/storage-plugin 1.9.0 → 1.11.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 +24 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +38 -56
- package/dist/rozenite.json +1 -1
- package/package.json +9 -6
- package/src/react-native/adapters/__tests__/mmkv.test.ts +300 -5
- package/src/react-native/adapters/mmkv.ts +51 -40
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/dist/devtools/assets/panel-DVxtKLI9.css +0 -1
- package/dist/devtools/assets/panel-DnjbFMGo.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
2
|
+
import { EditorState as CMEditorState } from '@codemirror/state';
|
|
3
|
+
import { EditorView, keymap } from '@codemirror/view';
|
|
4
|
+
import { useEffect, useReducer, useRef } from 'react';
|
|
5
|
+
import { bytesToAsciiPreview } from './binary';
|
|
6
|
+
import {
|
|
7
|
+
initialState,
|
|
8
|
+
reduce,
|
|
9
|
+
type EditorMode,
|
|
10
|
+
} from './binary-value-editor-state';
|
|
11
|
+
|
|
12
|
+
export type BinaryValueEditorProps = {
|
|
13
|
+
initialBytes?: number[];
|
|
14
|
+
onChange: (bytes: number[] | null) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const darkTheme = EditorView.theme(
|
|
18
|
+
{
|
|
19
|
+
'&': {
|
|
20
|
+
color: '#e5e7eb',
|
|
21
|
+
backgroundColor: '#111827',
|
|
22
|
+
fontSize: '12px',
|
|
23
|
+
},
|
|
24
|
+
'.cm-content': {
|
|
25
|
+
caretColor: '#60a5fa',
|
|
26
|
+
fontFamily:
|
|
27
|
+
'ui-monospace, "Cascadia Mono", "Fira Code", Menlo, monospace',
|
|
28
|
+
padding: '8px',
|
|
29
|
+
},
|
|
30
|
+
'.cm-focused': { outline: 'none' },
|
|
31
|
+
'.cm-gutters': { display: 'none' },
|
|
32
|
+
'.cm-scroller': { overflow: 'auto' },
|
|
33
|
+
},
|
|
34
|
+
{ dark: true },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const ModeButton = ({
|
|
38
|
+
label,
|
|
39
|
+
active,
|
|
40
|
+
onClick,
|
|
41
|
+
}: {
|
|
42
|
+
label: string;
|
|
43
|
+
active: boolean;
|
|
44
|
+
onClick: () => void;
|
|
45
|
+
}) => (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
50
|
+
active
|
|
51
|
+
? 'bg-blue-600 text-white'
|
|
52
|
+
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
{label}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const BinaryValueEditor = ({
|
|
60
|
+
initialBytes,
|
|
61
|
+
onChange,
|
|
62
|
+
}: BinaryValueEditorProps) => {
|
|
63
|
+
const [state, dispatch] = useReducer(reduce, undefined, () =>
|
|
64
|
+
initialState({ initialBytes }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
69
|
+
const stateRef = useRef(state);
|
|
70
|
+
stateRef.current = state;
|
|
71
|
+
const onChangeRef = useRef(onChange);
|
|
72
|
+
onChangeRef.current = onChange;
|
|
73
|
+
|
|
74
|
+
// Mount the CodeMirror view once. The update listener compares the
|
|
75
|
+
// doc to the latest reducer text (via stateRef) so paste-or-type
|
|
76
|
+
// events round-trip through the reducer instead of looping back.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!hostRef.current) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const view = new EditorView({
|
|
82
|
+
state: CMEditorState.create({
|
|
83
|
+
doc: stateRef.current.text,
|
|
84
|
+
extensions: [
|
|
85
|
+
history(),
|
|
86
|
+
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
87
|
+
darkTheme,
|
|
88
|
+
EditorView.lineWrapping,
|
|
89
|
+
EditorView.updateListener.of((update) => {
|
|
90
|
+
if (!update.docChanged) return;
|
|
91
|
+
const newText = update.state.doc.toString();
|
|
92
|
+
if (newText === stateRef.current.text) return;
|
|
93
|
+
const isPaste = update.transactions.some((tr) =>
|
|
94
|
+
tr.isUserEvent('input.paste'),
|
|
95
|
+
);
|
|
96
|
+
dispatch({
|
|
97
|
+
type: isPaste ? 'normalize-paste' : 'set-text',
|
|
98
|
+
text: newText,
|
|
99
|
+
});
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
parent: hostRef.current,
|
|
104
|
+
});
|
|
105
|
+
viewRef.current = view;
|
|
106
|
+
return () => {
|
|
107
|
+
view.destroy();
|
|
108
|
+
viewRef.current = null;
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Push reducer text into CodeMirror only when they diverge.
|
|
113
|
+
// This fires for paste normalization and mode switches.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const view = viewRef.current;
|
|
116
|
+
if (!view) return;
|
|
117
|
+
const current = view.state.doc.toString();
|
|
118
|
+
if (current === state.text) return;
|
|
119
|
+
view.dispatch({
|
|
120
|
+
changes: { from: 0, to: current.length, insert: state.text },
|
|
121
|
+
});
|
|
122
|
+
}, [state.text]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
onChangeRef.current(state.bytes);
|
|
126
|
+
}, [state.bytes]);
|
|
127
|
+
|
|
128
|
+
const byteCount = state.bytes?.length ?? 0;
|
|
129
|
+
const asciiPreview = state.bytes ? bytesToAsciiPreview(state.bytes) : '';
|
|
130
|
+
|
|
131
|
+
const handleModeChange = (mode: EditorMode) => {
|
|
132
|
+
if (state.mode === mode) return;
|
|
133
|
+
dispatch({ type: 'switch-mode', mode });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex flex-col gap-2">
|
|
138
|
+
<div className="flex items-center gap-1">
|
|
139
|
+
<ModeButton
|
|
140
|
+
label="Hex"
|
|
141
|
+
active={state.mode === 'hex'}
|
|
142
|
+
onClick={() => handleModeChange('hex')}
|
|
143
|
+
/>
|
|
144
|
+
<ModeButton
|
|
145
|
+
label="Base64"
|
|
146
|
+
active={state.mode === 'base64'}
|
|
147
|
+
onClick={() => handleModeChange('base64')}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
ref={hostRef}
|
|
153
|
+
className="min-h-[120px] max-h-[300px] overflow-auto rounded border border-gray-700 bg-gray-900"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<div className="flex flex-col gap-1 text-xs">
|
|
157
|
+
<div className="text-gray-400">{byteCount} bytes</div>
|
|
158
|
+
{asciiPreview && (
|
|
159
|
+
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-400">
|
|
160
|
+
ASCII:{' '}
|
|
161
|
+
<span className="font-mono text-gray-300">{asciiPreview}</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{state.error && <div className="text-red-400">{state.error}</div>}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
};
|
package/src/ui/binary.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type ParseResult<T> =
|
|
2
|
+
| { ok: true; value: T }
|
|
3
|
+
| { ok: false; error: string };
|
|
4
|
+
|
|
5
|
+
const BYTES_PER_LINE = 16;
|
|
6
|
+
const BYTES_PER_GROUP = 8;
|
|
7
|
+
// 16 bytes × 2 hex chars + 14 single inter-byte spaces + 1 extra
|
|
8
|
+
// space between groups = 32 + 14 + 2 = 48
|
|
9
|
+
const HEX_SECTION_WIDTH = 48;
|
|
10
|
+
const OFFSET_WIDTH = 8;
|
|
11
|
+
const ASCII_PRINTABLE_MIN = 0x20;
|
|
12
|
+
const ASCII_PRINTABLE_MAX = 0x7e;
|
|
13
|
+
|
|
14
|
+
const toHexPair = (byte: number): string =>
|
|
15
|
+
byte.toString(16).toUpperCase().padStart(2, '0');
|
|
16
|
+
|
|
17
|
+
const toAsciiChar = (byte: number): string =>
|
|
18
|
+
byte >= ASCII_PRINTABLE_MIN && byte <= ASCII_PRINTABLE_MAX
|
|
19
|
+
? String.fromCharCode(byte)
|
|
20
|
+
: '.';
|
|
21
|
+
|
|
22
|
+
const formatHexLine = (slice: readonly number[]): string => {
|
|
23
|
+
const left = slice.slice(0, BYTES_PER_GROUP).map(toHexPair).join(' ');
|
|
24
|
+
const right = slice.slice(BYTES_PER_GROUP).map(toHexPair).join(' ');
|
|
25
|
+
return right ? `${left} ${right}` : left;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const bytesToGroupedHex = (bytes: readonly number[]): string => {
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
for (let i = 0; i < bytes.length; i += BYTES_PER_LINE) {
|
|
31
|
+
lines.push(formatHexLine(bytes.slice(i, i + BYTES_PER_LINE)));
|
|
32
|
+
}
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const bytesToHexdump = (bytes: readonly number[]): string => {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
for (let i = 0; i < bytes.length; i += BYTES_PER_LINE) {
|
|
39
|
+
const slice = bytes.slice(i, i + BYTES_PER_LINE);
|
|
40
|
+
const offset = i.toString(16).padStart(OFFSET_WIDTH, '0');
|
|
41
|
+
const hex = formatHexLine(slice).padEnd(HEX_SECTION_WIDTH, ' ');
|
|
42
|
+
const ascii = slice.map(toAsciiChar).join('');
|
|
43
|
+
lines.push(`${offset} ${hex} |${ascii}|`);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const bytesToAsciiPreview = (bytes: readonly number[]): string =>
|
|
49
|
+
bytes.map(toAsciiChar).join('');
|
|
50
|
+
|
|
51
|
+
export const bytesToBase64 = (bytes: readonly number[]): string => {
|
|
52
|
+
let binary = '';
|
|
53
|
+
for (const byte of bytes) {
|
|
54
|
+
binary += String.fromCharCode(byte);
|
|
55
|
+
}
|
|
56
|
+
return btoa(binary);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const compactBufferPreview = (
|
|
60
|
+
bytes: readonly number[],
|
|
61
|
+
options: { maxBytes?: number } = {},
|
|
62
|
+
): string => {
|
|
63
|
+
const maxBytes = options.maxBytes ?? BYTES_PER_GROUP;
|
|
64
|
+
const sizeLabel = `${bytes.length} B`;
|
|
65
|
+
if (bytes.length === 0) {
|
|
66
|
+
return sizeLabel;
|
|
67
|
+
}
|
|
68
|
+
const shown = bytes.slice(0, maxBytes).map(toHexPair).join(' ');
|
|
69
|
+
if (bytes.length <= maxBytes) {
|
|
70
|
+
return `${shown} ${sizeLabel}`;
|
|
71
|
+
}
|
|
72
|
+
return `${shown} … ${sizeLabel}`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Matches a 4+ hex-digit offset followed by either a colon-space or
|
|
76
|
+
// at least two spaces. Avoids false-positives on grouped-hex lines
|
|
77
|
+
// where bytes are separated by single spaces.
|
|
78
|
+
const HEXDUMP_OFFSET_RE = /^[0-9a-fA-F]{4,16}(?::\s+|\s{2,})/;
|
|
79
|
+
const TRAILING_ASCII_COLUMN_RE = /\|[^|]*\|\s*$/;
|
|
80
|
+
|
|
81
|
+
export const hexInputToBytes = (input: string): ParseResult<number[]> => {
|
|
82
|
+
const cleaned = input
|
|
83
|
+
.split(/\r?\n/)
|
|
84
|
+
.map((line) =>
|
|
85
|
+
line.replace(TRAILING_ASCII_COLUMN_RE, '').replace(HEXDUMP_OFFSET_RE, ''),
|
|
86
|
+
)
|
|
87
|
+
.join('')
|
|
88
|
+
.replace(/0x/gi, '')
|
|
89
|
+
.replace(/\s+/g, '');
|
|
90
|
+
|
|
91
|
+
if (cleaned.length === 0) {
|
|
92
|
+
return { ok: false, error: 'Enter at least one byte.' };
|
|
93
|
+
}
|
|
94
|
+
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
|
|
95
|
+
return { ok: false, error: 'Hex input contains invalid characters.' };
|
|
96
|
+
}
|
|
97
|
+
if (cleaned.length % 2 !== 0) {
|
|
98
|
+
return { ok: false, error: 'Hex input must contain complete bytes.' };
|
|
99
|
+
}
|
|
100
|
+
const bytes: number[] = [];
|
|
101
|
+
for (let i = 0; i < cleaned.length; i += 2) {
|
|
102
|
+
bytes.push(parseInt(cleaned.slice(i, i + 2), 16));
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, value: bytes };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const base64ToBytes = (input: string): ParseResult<number[]> => {
|
|
108
|
+
const cleaned = input.replace(/\s+/g, '');
|
|
109
|
+
if (cleaned.length === 0) {
|
|
110
|
+
return { ok: false, error: 'Enter at least one byte.' };
|
|
111
|
+
}
|
|
112
|
+
let binary: string;
|
|
113
|
+
try {
|
|
114
|
+
binary = atob(cleaned);
|
|
115
|
+
} catch {
|
|
116
|
+
return { ok: false, error: 'Base64 input is invalid.' };
|
|
117
|
+
}
|
|
118
|
+
const bytes: number[] = [];
|
|
119
|
+
for (let i = 0; i < binary.length; i++) {
|
|
120
|
+
bytes.push(binary.charCodeAt(i));
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, value: bytes };
|
|
123
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { X, Edit3 } from 'lucide-react';
|
|
3
3
|
import type {
|
|
4
4
|
StorageEntry,
|
|
@@ -6,10 +6,16 @@ import type {
|
|
|
6
6
|
StorageEntryValue,
|
|
7
7
|
} from '../shared/types';
|
|
8
8
|
import { ConfirmDialog } from './confirm-dialog';
|
|
9
|
+
import { TypedValueEditor } from './typed-value-editor';
|
|
10
|
+
import { defaultValueForType } from './type-conversion';
|
|
9
11
|
|
|
10
12
|
export type EditEntryDialogProps = {
|
|
11
13
|
isOpen: boolean;
|
|
12
14
|
onClose: () => void;
|
|
15
|
+
// Called with the runtime value in its native JS shape — the caller
|
|
16
|
+
// infers the storage type from `typeof newValue`. Lets the dialog
|
|
17
|
+
// change the entry's stored type (e.g. string → buffer) without a
|
|
18
|
+
// dedicated prop for the new type.
|
|
13
19
|
onEditEntry: (key: string, newValue: StorageEntryValue) => void;
|
|
14
20
|
entry: StorageEntry | null;
|
|
15
21
|
supportedTypes: StorageEntryType[];
|
|
@@ -22,147 +28,73 @@ export const EditEntryDialog = ({
|
|
|
22
28
|
entry,
|
|
23
29
|
supportedTypes,
|
|
24
30
|
}: EditEntryDialogProps) => {
|
|
25
|
-
const [
|
|
31
|
+
const [currentType, setCurrentType] = useState<StorageEntryType>('string');
|
|
32
|
+
const [currentValue, setCurrentValue] = useState<StorageEntryValue | null>(
|
|
33
|
+
defaultValueForType('string'),
|
|
34
|
+
);
|
|
26
35
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
27
36
|
isOpen: boolean;
|
|
28
37
|
title: string;
|
|
29
38
|
message: string;
|
|
30
|
-
|
|
31
|
-
onConfirm?: () => void;
|
|
32
|
-
}>({ isOpen: false, title: '', message: '', type: 'alert' });
|
|
39
|
+
}>({ isOpen: false, title: '', message: '' });
|
|
33
40
|
|
|
34
41
|
useEffect(() => {
|
|
35
42
|
if (entry && isOpen) {
|
|
36
|
-
|
|
43
|
+
setCurrentType(entry.type);
|
|
44
|
+
setCurrentValue(entry.value);
|
|
37
45
|
}
|
|
38
46
|
}, [entry, isOpen]);
|
|
39
47
|
|
|
40
|
-
const
|
|
41
|
-
() => !!entry && supportedTypes.includes(entry.type),
|
|
42
|
-
[entry, supportedTypes]
|
|
43
|
-
);
|
|
48
|
+
const isCurrentTypeSupported = supportedTypes.includes(currentType);
|
|
44
49
|
|
|
45
|
-
const
|
|
46
|
-
|
|
50
|
+
const resetAndClose = () => {
|
|
51
|
+
setCurrentType('string');
|
|
52
|
+
setCurrentValue(defaultValueForType('string'));
|
|
47
53
|
onClose();
|
|
48
54
|
};
|
|
49
55
|
|
|
50
|
-
const
|
|
51
|
-
if (!entry) return;
|
|
56
|
+
const handleSave = () => {
|
|
57
|
+
if (!entry || currentValue === null) return;
|
|
52
58
|
|
|
53
|
-
if (!
|
|
59
|
+
if (!isCurrentTypeSupported) {
|
|
54
60
|
setConfirmDialog({
|
|
55
61
|
isOpen: true,
|
|
56
62
|
title: 'Unsupported Type',
|
|
57
|
-
message: 'This storage does not support the
|
|
58
|
-
type: 'alert',
|
|
59
|
-
});
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
let newValue: StorageEntryValue;
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
switch (entry.type) {
|
|
67
|
-
case 'string':
|
|
68
|
-
newValue = editValue;
|
|
69
|
-
break;
|
|
70
|
-
case 'number':
|
|
71
|
-
newValue = Number(editValue);
|
|
72
|
-
if (Number.isNaN(newValue)) {
|
|
73
|
-
throw new Error('Invalid number');
|
|
74
|
-
}
|
|
75
|
-
break;
|
|
76
|
-
case 'boolean':
|
|
77
|
-
if (editValue !== 'true' && editValue !== 'false') {
|
|
78
|
-
throw new Error('Boolean value must be "true" or "false"');
|
|
79
|
-
}
|
|
80
|
-
newValue = editValue === 'true';
|
|
81
|
-
break;
|
|
82
|
-
case 'buffer':
|
|
83
|
-
newValue = JSON.parse(editValue);
|
|
84
|
-
if (!Array.isArray(newValue) || !newValue.every((v) => typeof v === 'number')) {
|
|
85
|
-
throw new Error('Buffer must be an array of numbers');
|
|
86
|
-
}
|
|
87
|
-
break;
|
|
88
|
-
default:
|
|
89
|
-
throw new Error('Invalid type');
|
|
90
|
-
}
|
|
91
|
-
} catch (error) {
|
|
92
|
-
setConfirmDialog({
|
|
93
|
-
isOpen: true,
|
|
94
|
-
title: 'Invalid Value',
|
|
95
|
-
message: `Invalid value for ${entry.type}: ${
|
|
96
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
97
|
-
}`,
|
|
98
|
-
type: 'alert',
|
|
63
|
+
message: 'This storage does not support the selected type.',
|
|
99
64
|
});
|
|
100
65
|
return;
|
|
101
66
|
}
|
|
102
67
|
|
|
103
|
-
onEditEntry(entry.key,
|
|
104
|
-
|
|
68
|
+
onEditEntry(entry.key, currentValue);
|
|
69
|
+
resetAndClose();
|
|
105
70
|
};
|
|
106
71
|
|
|
107
72
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
108
73
|
if (event.key === 'Escape') {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
handleEditEntry();
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const getInputType = (type: StorageEntryType) => {
|
|
116
|
-
if (type === 'number') {
|
|
117
|
-
return 'number';
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return 'text';
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const getPlaceholder = (type: StorageEntryType) => {
|
|
124
|
-
if (type === 'string') {
|
|
125
|
-
return 'Enter string value';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (type === 'number') {
|
|
129
|
-
return 'Enter number value';
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (type === 'boolean') {
|
|
133
|
-
return 'Enter true or false';
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return 'Enter array as JSON, e.g., [1, 2, 3]';
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const getTypeColorClass = (type: StorageEntryType) => {
|
|
140
|
-
if (type === 'string') {
|
|
141
|
-
return 'bg-green-600';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (type === 'number') {
|
|
145
|
-
return 'bg-blue-600';
|
|
74
|
+
resetAndClose();
|
|
75
|
+
return;
|
|
146
76
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return 'bg-yellow-600';
|
|
77
|
+
if (event.key === 'Enter' && currentType !== 'buffer') {
|
|
78
|
+
handleSave();
|
|
150
79
|
}
|
|
151
|
-
|
|
152
|
-
return 'bg-purple-600';
|
|
153
80
|
};
|
|
154
81
|
|
|
155
82
|
if (!isOpen || !entry) {
|
|
156
83
|
return null;
|
|
157
84
|
}
|
|
158
85
|
|
|
86
|
+
// Unsavable when the type isn't supported, or when the current value
|
|
87
|
+
// is null (the hex editor signals invalid / empty hex as null —
|
|
88
|
+
// empty bytes are not a meaningful entry to save).
|
|
89
|
+
const isSaveDisabled = !isCurrentTypeSupported || currentValue === null;
|
|
90
|
+
|
|
159
91
|
return (
|
|
160
92
|
<div
|
|
161
93
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
162
|
-
onClick={
|
|
94
|
+
onClick={resetAndClose}
|
|
163
95
|
>
|
|
164
96
|
<div
|
|
165
|
-
className="bg-gray-800 rounded-lg p-6 w-
|
|
97
|
+
className="bg-gray-800 rounded-lg p-6 w-[32rem] max-w-full mx-4"
|
|
166
98
|
onClick={(event) => event.stopPropagation()}
|
|
167
99
|
onKeyDown={handleKeyDown}
|
|
168
100
|
>
|
|
@@ -172,7 +104,7 @@ export const EditEntryDialog = ({
|
|
|
172
104
|
<h2 className="text-lg font-semibold text-gray-100">Edit Entry</h2>
|
|
173
105
|
</div>
|
|
174
106
|
<button
|
|
175
|
-
onClick={
|
|
107
|
+
onClick={resetAndClose}
|
|
176
108
|
className="p-1 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
|
|
177
109
|
title="Close dialog"
|
|
178
110
|
>
|
|
@@ -182,31 +114,15 @@ export const EditEntryDialog = ({
|
|
|
182
114
|
|
|
183
115
|
<div className="space-y-4">
|
|
184
116
|
<div>
|
|
185
|
-
<label className="block text-sm font-medium text-gray-200 mb-1">
|
|
117
|
+
<label className="block text-sm font-medium text-gray-200 mb-1">
|
|
118
|
+
Key
|
|
119
|
+
</label>
|
|
186
120
|
<div className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 font-mono">
|
|
187
121
|
{entry.key}
|
|
188
122
|
</div>
|
|
189
|
-
<p className="text-xs text-gray-400 mt-1">
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<div>
|
|
193
|
-
<label className="block text-sm font-medium text-gray-200 mb-1">Type</label>
|
|
194
|
-
<div className="flex items-center">
|
|
195
|
-
<span
|
|
196
|
-
className={`px-2 py-1 text-xs font-medium rounded text-white ${getTypeColorClass(
|
|
197
|
-
entry.type
|
|
198
|
-
)}`}
|
|
199
|
-
>
|
|
200
|
-
{entry.type}
|
|
201
|
-
</span>
|
|
202
|
-
</div>
|
|
203
|
-
{!isTypeSupported ? (
|
|
204
|
-
<p className="text-xs text-amber-400 mt-1">
|
|
205
|
-
This storage does not support {entry.type} values.
|
|
206
|
-
</p>
|
|
207
|
-
) : (
|
|
208
|
-
<p className="text-xs text-gray-400 mt-1">Type cannot be changed during editing</p>
|
|
209
|
-
)}
|
|
123
|
+
<p className="text-xs text-gray-400 mt-1">
|
|
124
|
+
Key cannot be changed during editing
|
|
125
|
+
</p>
|
|
210
126
|
</div>
|
|
211
127
|
|
|
212
128
|
<div>
|
|
@@ -216,31 +132,20 @@ export const EditEntryDialog = ({
|
|
|
216
132
|
>
|
|
217
133
|
Value
|
|
218
134
|
</label>
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
type={getInputType(entry.type)}
|
|
234
|
-
value={editValue}
|
|
235
|
-
onChange={(event) => setEditValue(event.target.value)}
|
|
236
|
-
placeholder={getPlaceholder(entry.type)}
|
|
237
|
-
className="w-full px-3 py-2 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"
|
|
238
|
-
autoFocus
|
|
239
|
-
/>
|
|
240
|
-
)}
|
|
241
|
-
{entry.type === 'buffer' && (
|
|
242
|
-
<p className="text-xs text-gray-400 mt-1">
|
|
243
|
-
Enter as JSON array of numbers, e.g., [1, 2, 3, 255]
|
|
135
|
+
<TypedValueEditor
|
|
136
|
+
supportedTypes={supportedTypes}
|
|
137
|
+
type={currentType}
|
|
138
|
+
value={currentValue}
|
|
139
|
+
onChange={(nextType, nextValue) => {
|
|
140
|
+
setCurrentType(nextType);
|
|
141
|
+
setCurrentValue(nextValue);
|
|
142
|
+
}}
|
|
143
|
+
inputId="edit-entry-value"
|
|
144
|
+
autoFocus
|
|
145
|
+
/>
|
|
146
|
+
{!isCurrentTypeSupported && (
|
|
147
|
+
<p className="text-xs text-amber-400 mt-1">
|
|
148
|
+
This storage does not support {currentType} values.
|
|
244
149
|
</p>
|
|
245
150
|
)}
|
|
246
151
|
</div>
|
|
@@ -248,14 +153,14 @@ export const EditEntryDialog = ({
|
|
|
248
153
|
|
|
249
154
|
<div className="flex items-center justify-end gap-2 mt-6">
|
|
250
155
|
<button
|
|
251
|
-
onClick={
|
|
156
|
+
onClick={resetAndClose}
|
|
252
157
|
className="px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
253
158
|
>
|
|
254
159
|
Cancel
|
|
255
160
|
</button>
|
|
256
161
|
<button
|
|
257
|
-
onClick={
|
|
258
|
-
disabled={
|
|
162
|
+
onClick={handleSave}
|
|
163
|
+
disabled={isSaveDisabled}
|
|
259
164
|
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
|
260
165
|
>
|
|
261
166
|
Save Changes
|
|
@@ -265,15 +170,13 @@ export const EditEntryDialog = ({
|
|
|
265
170
|
|
|
266
171
|
<ConfirmDialog
|
|
267
172
|
isOpen={confirmDialog.isOpen}
|
|
268
|
-
onClose={() =>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
}}
|
|
173
|
+
onClose={() =>
|
|
174
|
+
setConfirmDialog((previous) => ({ ...previous, isOpen: false }))
|
|
175
|
+
}
|
|
176
|
+
onConfirm={() => {}}
|
|
274
177
|
title={confirmDialog.title}
|
|
275
178
|
message={confirmDialog.message}
|
|
276
|
-
type=
|
|
179
|
+
type="alert"
|
|
277
180
|
/>
|
|
278
181
|
</div>
|
|
279
182
|
);
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
StorageEntryType,
|
|
16
16
|
StorageEntryValue,
|
|
17
17
|
} from '../shared/types';
|
|
18
|
+
import { compactBufferPreview } from './binary';
|
|
18
19
|
import { ConfirmDialog } from './confirm-dialog';
|
|
19
20
|
import { EditEntryDialog } from './edit-entry-dialog';
|
|
20
21
|
|
|
@@ -64,7 +65,7 @@ export const EditableTable = ({
|
|
|
64
65
|
<div className="flex items-center">
|
|
65
66
|
<span
|
|
66
67
|
className={`px-2 py-1 text-xs font-medium rounded text-white ${getTypeColorClass(
|
|
67
|
-
type
|
|
68
|
+
type,
|
|
68
69
|
)}`}
|
|
69
70
|
>
|
|
70
71
|
{type}
|
|
@@ -110,7 +111,7 @@ export const EditableTable = ({
|
|
|
110
111
|
),
|
|
111
112
|
}),
|
|
112
113
|
],
|
|
113
|
-
[onDeleteEntry]
|
|
114
|
+
[onDeleteEntry],
|
|
114
115
|
);
|
|
115
116
|
|
|
116
117
|
const table = useReactTable({
|
|
@@ -186,7 +187,7 @@ export const EditableTable = ({
|
|
|
186
187
|
? null
|
|
187
188
|
: flexRender(
|
|
188
189
|
header.column.columnDef.header,
|
|
189
|
-
header.getContext()
|
|
190
|
+
header.getContext(),
|
|
190
191
|
)}
|
|
191
192
|
{header.column.getCanSort() && (
|
|
192
193
|
<span className="text-gray-500">
|
|
@@ -283,16 +284,17 @@ const formatValue = (entry: StorageEntry) => {
|
|
|
283
284
|
|
|
284
285
|
if (entry.type === 'boolean') {
|
|
285
286
|
return (
|
|
286
|
-
<span
|
|
287
|
+
<span
|
|
288
|
+
className={`font-mono ${entry.value ? 'text-green-400' : 'text-red-400'}`}
|
|
289
|
+
>
|
|
287
290
|
{entry.value ? 'true' : 'false'}
|
|
288
291
|
</span>
|
|
289
292
|
);
|
|
290
293
|
}
|
|
291
294
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return <span className="text-purple-300 font-mono">{displayValue}</span>;
|
|
295
|
+
return (
|
|
296
|
+
<span className="text-purple-300 font-mono">
|
|
297
|
+
{compactBufferPreview(entry.value)}
|
|
298
|
+
</span>
|
|
299
|
+
);
|
|
298
300
|
};
|