ralph-cli-sandboxed 0.2.9 → 0.4.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/README.md +99 -15
- package/dist/commands/action.d.ts +7 -0
- package/dist/commands/action.js +276 -0
- package/dist/commands/chat.d.ts +8 -0
- package/dist/commands/chat.js +701 -0
- package/dist/commands/config.d.ts +1 -0
- package/dist/commands/config.js +51 -0
- package/dist/commands/daemon.d.ts +23 -0
- package/dist/commands/daemon.js +422 -0
- package/dist/commands/docker.js +82 -4
- package/dist/commands/fix-config.d.ts +4 -0
- package/dist/commands/fix-config.js +388 -0
- package/dist/commands/help.js +80 -0
- package/dist/commands/init.js +135 -1
- package/dist/commands/listen.d.ts +8 -0
- package/dist/commands/listen.js +280 -0
- package/dist/commands/notify.d.ts +7 -0
- package/dist/commands/notify.js +165 -0
- package/dist/commands/once.js +8 -8
- package/dist/commands/prd.js +2 -2
- package/dist/commands/run.js +25 -12
- package/dist/config/languages.json +4 -0
- package/dist/index.js +14 -0
- package/dist/providers/telegram.d.ts +39 -0
- package/dist/providers/telegram.js +256 -0
- package/dist/templates/macos-scripts.d.ts +42 -0
- package/dist/templates/macos-scripts.js +448 -0
- package/dist/tui/ConfigEditor.d.ts +7 -0
- package/dist/tui/ConfigEditor.js +313 -0
- package/dist/tui/components/ArrayEditor.d.ts +22 -0
- package/dist/tui/components/ArrayEditor.js +193 -0
- package/dist/tui/components/BooleanToggle.d.ts +19 -0
- package/dist/tui/components/BooleanToggle.js +43 -0
- package/dist/tui/components/EditorPanel.d.ts +50 -0
- package/dist/tui/components/EditorPanel.js +232 -0
- package/dist/tui/components/HelpPanel.d.ts +13 -0
- package/dist/tui/components/HelpPanel.js +69 -0
- package/dist/tui/components/JsonSnippetEditor.d.ts +24 -0
- package/dist/tui/components/JsonSnippetEditor.js +380 -0
- package/dist/tui/components/KeyValueEditor.d.ts +34 -0
- package/dist/tui/components/KeyValueEditor.js +261 -0
- package/dist/tui/components/ObjectEditor.d.ts +23 -0
- package/dist/tui/components/ObjectEditor.js +227 -0
- package/dist/tui/components/PresetSelector.d.ts +23 -0
- package/dist/tui/components/PresetSelector.js +58 -0
- package/dist/tui/components/Preview.d.ts +18 -0
- package/dist/tui/components/Preview.js +190 -0
- package/dist/tui/components/ScrollableContainer.d.ts +38 -0
- package/dist/tui/components/ScrollableContainer.js +77 -0
- package/dist/tui/components/SectionNav.d.ts +31 -0
- package/dist/tui/components/SectionNav.js +130 -0
- package/dist/tui/components/StringEditor.d.ts +21 -0
- package/dist/tui/components/StringEditor.js +29 -0
- package/dist/tui/hooks/useConfig.d.ts +16 -0
- package/dist/tui/hooks/useConfig.js +89 -0
- package/dist/tui/hooks/useTerminalSize.d.ts +21 -0
- package/dist/tui/hooks/useTerminalSize.js +48 -0
- package/dist/tui/utils/presets.d.ts +52 -0
- package/dist/tui/utils/presets.js +191 -0
- package/dist/tui/utils/validation.d.ts +49 -0
- package/dist/tui/utils/validation.js +198 -0
- package/dist/utils/chat-client.d.ts +144 -0
- package/dist/utils/chat-client.js +102 -0
- package/dist/utils/config.d.ts +52 -0
- package/dist/utils/daemon-client.d.ts +36 -0
- package/dist/utils/daemon-client.js +70 -0
- package/dist/utils/message-queue.d.ts +58 -0
- package/dist/utils/message-queue.js +133 -0
- package/dist/utils/notification.d.ts +28 -1
- package/dist/utils/notification.js +146 -20
- package/docs/MACOS-DEVELOPMENT.md +435 -0
- package/docs/RALPH-SETUP-TEMPLATE.md +262 -0
- package/package.json +6 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useMemo } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { useConfig } from "./hooks/useConfig.js";
|
|
5
|
+
import { useTerminalSize } from "./hooks/useTerminalSize.js";
|
|
6
|
+
import { SectionNav } from "./components/SectionNav.js";
|
|
7
|
+
import { EditorPanel, getValueAtPath, inferFieldType } from "./components/EditorPanel.js";
|
|
8
|
+
import { StringEditor } from "./components/StringEditor.js";
|
|
9
|
+
import { BooleanToggle } from "./components/BooleanToggle.js";
|
|
10
|
+
import { ArrayEditor } from "./components/ArrayEditor.js";
|
|
11
|
+
import { ObjectEditor } from "./components/ObjectEditor.js";
|
|
12
|
+
import { KeyValueEditor } from "./components/KeyValueEditor.js";
|
|
13
|
+
import { JsonSnippetEditor } from "./components/JsonSnippetEditor.js";
|
|
14
|
+
import { Preview } from "./components/Preview.js";
|
|
15
|
+
import { HelpPanel } from "./components/HelpPanel.js";
|
|
16
|
+
import { PresetSelector } from "./components/PresetSelector.js";
|
|
17
|
+
import { validateConfig } from "./utils/validation.js";
|
|
18
|
+
import { sectionHasPresets, applyPreset } from "./utils/presets.js";
|
|
19
|
+
/**
|
|
20
|
+
* Set a value at a dot-notation path in an object (immutably).
|
|
21
|
+
*/
|
|
22
|
+
function setValueAtPath(obj, path, value) {
|
|
23
|
+
const parts = path.split(".");
|
|
24
|
+
const result = JSON.parse(JSON.stringify(obj));
|
|
25
|
+
let current = result;
|
|
26
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
27
|
+
const part = parts[i];
|
|
28
|
+
if (current[part] === undefined || current[part] === null) {
|
|
29
|
+
current[part] = {};
|
|
30
|
+
}
|
|
31
|
+
current = current[part];
|
|
32
|
+
}
|
|
33
|
+
const lastPart = parts[parts.length - 1];
|
|
34
|
+
current[lastPart] = value;
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* ConfigEditor is the main TUI application component.
|
|
39
|
+
* It provides a two-panel layout with section navigation and field editing.
|
|
40
|
+
*/
|
|
41
|
+
export function ConfigEditor() {
|
|
42
|
+
const { exit } = useApp();
|
|
43
|
+
const terminalSize = useTerminalSize();
|
|
44
|
+
const { config, loading, error, hasChanges, saveConfig, updateConfig, } = useConfig();
|
|
45
|
+
// Calculate available height for scrollable content
|
|
46
|
+
// Reserve lines for: header (2), status message (1), footer (2), borders (2)
|
|
47
|
+
const availableHeight = Math.max(8, terminalSize.rows - 7);
|
|
48
|
+
// Nav panel gets slightly less height for its content
|
|
49
|
+
const navMaxHeight = Math.max(4, availableHeight - 4);
|
|
50
|
+
// Editor panel gets full available height
|
|
51
|
+
const editorMaxHeight = Math.max(6, availableHeight - 2);
|
|
52
|
+
// Navigation state
|
|
53
|
+
const [selectedSection, setSelectedSection] = useState("basic");
|
|
54
|
+
const [selectedField, setSelectedField] = useState(undefined);
|
|
55
|
+
const [focusPane, setFocusPane] = useState("nav");
|
|
56
|
+
// Preview panel visibility
|
|
57
|
+
const [previewVisible, setPreviewVisible] = useState(true);
|
|
58
|
+
// Help panel visibility
|
|
59
|
+
const [helpVisible, setHelpVisible] = useState(false);
|
|
60
|
+
// JSON edit mode - when true, use JsonSnippetEditor for complex fields
|
|
61
|
+
const [jsonEditMode, setJsonEditMode] = useState(false);
|
|
62
|
+
// Status message for feedback
|
|
63
|
+
const [statusMessage, setStatusMessage] = useState(null);
|
|
64
|
+
// Track which sections have shown preset selector (to avoid repeat prompts)
|
|
65
|
+
const [visitedSections, setVisitedSections] = useState(new Set(["basic"]));
|
|
66
|
+
// Validation errors
|
|
67
|
+
const validationErrors = useMemo(() => {
|
|
68
|
+
if (!config)
|
|
69
|
+
return [];
|
|
70
|
+
return validateConfig(config).errors;
|
|
71
|
+
}, [config]);
|
|
72
|
+
// Get the current field value and type for the field editor
|
|
73
|
+
const currentFieldValue = useMemo(() => {
|
|
74
|
+
if (!config || !selectedField)
|
|
75
|
+
return undefined;
|
|
76
|
+
return getValueAtPath(config, selectedField);
|
|
77
|
+
}, [config, selectedField]);
|
|
78
|
+
const currentFieldType = useMemo(() => {
|
|
79
|
+
return inferFieldType(currentFieldValue);
|
|
80
|
+
}, [currentFieldValue]);
|
|
81
|
+
// Get the label for the current field
|
|
82
|
+
const currentFieldLabel = useMemo(() => {
|
|
83
|
+
if (!selectedField)
|
|
84
|
+
return "";
|
|
85
|
+
const parts = selectedField.split(".");
|
|
86
|
+
const lastPart = parts[parts.length - 1];
|
|
87
|
+
return lastPart
|
|
88
|
+
.replace(/([A-Z])/g, " $1")
|
|
89
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
90
|
+
.trim();
|
|
91
|
+
}, [selectedField]);
|
|
92
|
+
// Handle section selection
|
|
93
|
+
const handleSelectSection = useCallback((sectionId) => {
|
|
94
|
+
setSelectedSection(sectionId);
|
|
95
|
+
setSelectedField(undefined);
|
|
96
|
+
// Check if this section has presets and hasn't been visited yet
|
|
97
|
+
if (sectionHasPresets(sectionId) && !visitedSections.has(sectionId)) {
|
|
98
|
+
setFocusPane("preset-selector");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
setFocusPane("editor");
|
|
102
|
+
}
|
|
103
|
+
}, [visitedSections]);
|
|
104
|
+
// Handle preset selection
|
|
105
|
+
const handleSelectPreset = useCallback((preset) => {
|
|
106
|
+
if (!config)
|
|
107
|
+
return;
|
|
108
|
+
// Apply the preset to the config
|
|
109
|
+
updateConfig((currentConfig) => {
|
|
110
|
+
return applyPreset(currentConfig, preset);
|
|
111
|
+
});
|
|
112
|
+
// Mark section as visited
|
|
113
|
+
setVisitedSections((prev) => new Set([...prev, selectedSection]));
|
|
114
|
+
// Show status message
|
|
115
|
+
setStatusMessage(`Applied "${preset.name}" preset`);
|
|
116
|
+
setTimeout(() => setStatusMessage(null), 2000);
|
|
117
|
+
// Move to editor
|
|
118
|
+
setFocusPane("editor");
|
|
119
|
+
}, [config, updateConfig, selectedSection]);
|
|
120
|
+
// Handle skipping preset selection
|
|
121
|
+
const handleSkipPreset = useCallback(() => {
|
|
122
|
+
// Mark section as visited
|
|
123
|
+
setVisitedSections((prev) => new Set([...prev, selectedSection]));
|
|
124
|
+
setFocusPane("editor");
|
|
125
|
+
}, [selectedSection]);
|
|
126
|
+
// Handle canceling preset selection (go back to nav)
|
|
127
|
+
const handleCancelPreset = useCallback(() => {
|
|
128
|
+
setFocusPane("nav");
|
|
129
|
+
}, []);
|
|
130
|
+
// Handle field selection
|
|
131
|
+
const handleSelectField = useCallback((fieldPath, useJsonEditor = false) => {
|
|
132
|
+
setSelectedField(fieldPath);
|
|
133
|
+
setJsonEditMode(useJsonEditor);
|
|
134
|
+
setFocusPane("field-editor");
|
|
135
|
+
}, []);
|
|
136
|
+
// Handle going back from editor to nav
|
|
137
|
+
const handleBack = useCallback(() => {
|
|
138
|
+
if (focusPane === "field-editor") {
|
|
139
|
+
setSelectedField(undefined);
|
|
140
|
+
setFocusPane("editor");
|
|
141
|
+
}
|
|
142
|
+
else if (focusPane === "editor") {
|
|
143
|
+
setFocusPane("nav");
|
|
144
|
+
}
|
|
145
|
+
}, [focusPane]);
|
|
146
|
+
// Handle field value confirmation
|
|
147
|
+
const handleFieldConfirm = useCallback((newValue) => {
|
|
148
|
+
if (!selectedField)
|
|
149
|
+
return;
|
|
150
|
+
updateConfig((currentConfig) => {
|
|
151
|
+
return setValueAtPath(currentConfig, selectedField, newValue);
|
|
152
|
+
});
|
|
153
|
+
setSelectedField(undefined);
|
|
154
|
+
setJsonEditMode(false);
|
|
155
|
+
setFocusPane("editor");
|
|
156
|
+
setStatusMessage("Field updated");
|
|
157
|
+
setTimeout(() => setStatusMessage(null), 2000);
|
|
158
|
+
}, [selectedField, updateConfig]);
|
|
159
|
+
// Handle field edit cancel
|
|
160
|
+
const handleFieldCancel = useCallback(() => {
|
|
161
|
+
setSelectedField(undefined);
|
|
162
|
+
setJsonEditMode(false);
|
|
163
|
+
setFocusPane("editor");
|
|
164
|
+
}, []);
|
|
165
|
+
// Handle save
|
|
166
|
+
const handleSave = useCallback(() => {
|
|
167
|
+
// Validate before saving
|
|
168
|
+
if (!config) {
|
|
169
|
+
setStatusMessage("No configuration to save");
|
|
170
|
+
setTimeout(() => setStatusMessage(null), 2000);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const validation = validateConfig(config);
|
|
174
|
+
if (!validation.valid) {
|
|
175
|
+
const errorCount = validation.errors.length;
|
|
176
|
+
setStatusMessage(`Validation failed: ${errorCount} error${errorCount > 1 ? "s" : ""} found`);
|
|
177
|
+
setTimeout(() => setStatusMessage(null), 3000);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const success = saveConfig();
|
|
181
|
+
if (success) {
|
|
182
|
+
setStatusMessage("Configuration saved!");
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
setStatusMessage("Failed to save configuration");
|
|
186
|
+
}
|
|
187
|
+
setTimeout(() => setStatusMessage(null), 2000);
|
|
188
|
+
}, [saveConfig, config]);
|
|
189
|
+
// Handle quit
|
|
190
|
+
const handleQuit = useCallback(() => {
|
|
191
|
+
exit();
|
|
192
|
+
}, [exit]);
|
|
193
|
+
// Toggle preview visibility
|
|
194
|
+
const togglePreview = useCallback(() => {
|
|
195
|
+
setPreviewVisible((prev) => !prev);
|
|
196
|
+
}, []);
|
|
197
|
+
// Toggle help visibility
|
|
198
|
+
const toggleHelp = useCallback(() => {
|
|
199
|
+
setHelpVisible((prev) => !prev);
|
|
200
|
+
}, []);
|
|
201
|
+
// Global keyboard shortcuts (S for Save, Q for Quit, Tab for preview toggle, ? for help)
|
|
202
|
+
useInput((input, key) => {
|
|
203
|
+
// Only handle global shortcuts when not in field editor or preset selector
|
|
204
|
+
if (focusPane === "field-editor" || focusPane === "preset-selector")
|
|
205
|
+
return;
|
|
206
|
+
// ? key toggles help panel (takes priority when help is visible)
|
|
207
|
+
if (input === "?") {
|
|
208
|
+
toggleHelp();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// When help panel is visible, don't process other shortcuts
|
|
212
|
+
if (helpVisible)
|
|
213
|
+
return;
|
|
214
|
+
if (input.toUpperCase() === "S") {
|
|
215
|
+
handleSave();
|
|
216
|
+
}
|
|
217
|
+
else if (input.toUpperCase() === "Q") {
|
|
218
|
+
handleQuit();
|
|
219
|
+
}
|
|
220
|
+
else if (key.tab) {
|
|
221
|
+
// Tab to toggle JSON preview visibility
|
|
222
|
+
togglePreview();
|
|
223
|
+
}
|
|
224
|
+
else if (input === "l" || key.rightArrow) {
|
|
225
|
+
// l or right arrow to move focus to editor
|
|
226
|
+
if (focusPane === "nav") {
|
|
227
|
+
setFocusPane("editor");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (input === "h" || key.leftArrow) {
|
|
231
|
+
// h or left arrow to move focus to nav
|
|
232
|
+
if (focusPane === "editor") {
|
|
233
|
+
setFocusPane("nav");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (input === "p") {
|
|
237
|
+
// p to open preset selector if available for current section
|
|
238
|
+
if (focusPane === "editor" && sectionHasPresets(selectedSection)) {
|
|
239
|
+
setFocusPane("preset-selector");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}, { isActive: focusPane !== "field-editor" && focusPane !== "preset-selector" });
|
|
243
|
+
// Render loading state
|
|
244
|
+
if (loading) {
|
|
245
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), _jsx(Text, { dimColor: true, children: "Loading configuration..." })] }));
|
|
246
|
+
}
|
|
247
|
+
// Render error state
|
|
248
|
+
if (error || !config) {
|
|
249
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), _jsxs(Text, { color: "red", children: ["Error: ", error || "Failed to load configuration"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Q to quit" }) })] }));
|
|
250
|
+
}
|
|
251
|
+
// Render field editor overlay if a field is selected
|
|
252
|
+
const renderFieldEditor = () => {
|
|
253
|
+
if (!selectedField || focusPane !== "field-editor")
|
|
254
|
+
return null;
|
|
255
|
+
// Use JsonSnippetEditor for complex fields when J key was pressed or for certain field types
|
|
256
|
+
if (jsonEditMode) {
|
|
257
|
+
return (_jsx(JsonSnippetEditor, { label: currentFieldLabel, value: currentFieldValue, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight, maxWidth: terminalSize.columns }));
|
|
258
|
+
}
|
|
259
|
+
switch (currentFieldType) {
|
|
260
|
+
case "string":
|
|
261
|
+
return (_jsx(StringEditor, { label: currentFieldLabel, value: currentFieldValue || "", onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
|
|
262
|
+
case "boolean":
|
|
263
|
+
return (_jsx(BooleanToggle, { label: currentFieldLabel, value: currentFieldValue, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
|
|
264
|
+
case "number":
|
|
265
|
+
return (_jsx(StringEditor, { label: currentFieldLabel, value: String(currentFieldValue || ""), onConfirm: (val) => handleFieldConfirm(Number(val) || 0), onCancel: handleFieldCancel, isFocused: true, placeholder: "Enter a number" }));
|
|
266
|
+
case "array":
|
|
267
|
+
return (_jsx(ArrayEditor, { label: currentFieldLabel, items: currentFieldValue || [], onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
|
|
268
|
+
case "object":
|
|
269
|
+
// Handle object type - flatten to string key-value pairs
|
|
270
|
+
const objValue = (currentFieldValue || {});
|
|
271
|
+
const stringEntries = {};
|
|
272
|
+
for (const [k, v] of Object.entries(objValue)) {
|
|
273
|
+
stringEntries[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
274
|
+
}
|
|
275
|
+
// Check if this is a notification provider config field
|
|
276
|
+
const isNotificationProvider = selectedField &&
|
|
277
|
+
(selectedField === "notifications.ntfy" ||
|
|
278
|
+
selectedField === "notifications.pushover" ||
|
|
279
|
+
selectedField === "notifications.gotify");
|
|
280
|
+
if (isNotificationProvider) {
|
|
281
|
+
// Extract provider name from field path
|
|
282
|
+
const providerName = selectedField.split(".").pop() || "";
|
|
283
|
+
return (_jsx(KeyValueEditor, { label: currentFieldLabel, entries: stringEntries, providerName: providerName, onConfirm: (entries) => {
|
|
284
|
+
// For notification providers, keep values as strings
|
|
285
|
+
handleFieldConfirm(entries);
|
|
286
|
+
}, onCancel: handleFieldCancel, isFocused: true }));
|
|
287
|
+
}
|
|
288
|
+
return (_jsx(ObjectEditor, { label: currentFieldLabel, entries: stringEntries, onConfirm: (entries) => {
|
|
289
|
+
// Try to parse JSON values back if they look like objects
|
|
290
|
+
const parsedEntries = {};
|
|
291
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
292
|
+
try {
|
|
293
|
+
if (v.startsWith("{") || v.startsWith("[")) {
|
|
294
|
+
parsedEntries[k] = JSON.parse(v);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
parsedEntries[k] = v;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
parsedEntries[k] = v;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
handleFieldConfirm(parsedEntries);
|
|
305
|
+
}, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
|
|
306
|
+
default:
|
|
307
|
+
// Unknown type - use string editor
|
|
308
|
+
return (_jsx(StringEditor, { label: currentFieldLabel, value: String(currentFieldValue || ""), onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return (_jsxs(Box, { flexDirection: "column", children: [helpVisible && (_jsx(HelpPanel, { visible: helpVisible, onClose: toggleHelp })), _jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), hasChanges && _jsx(Text, { color: "yellow", children: " (unsaved changes)" })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "[S] Save" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[Q] Quit" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[?] Help" })] })] }), statusMessage && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: statusMessage.includes("Failed") || statusMessage.includes("Validation") ? "red" : "green", children: statusMessage }) })), focusPane === "field-editor" ? (_jsx(Box, { children: renderFieldEditor() })) : focusPane === "preset-selector" ? (_jsx(Box, { children: _jsx(PresetSelector, { sectionId: selectedSection, config: config, onSelectPreset: handleSelectPreset, onSkip: handleSkipPreset, onCancel: handleCancelPreset, isFocused: true }) })) : (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(SectionNav, { selectedSection: selectedSection, onSelectSection: handleSelectSection, isFocused: focusPane === "nav", maxHeight: navMaxHeight }) }), _jsx(Box, { flexGrow: 1, children: _jsx(EditorPanel, { config: config, selectedSection: selectedSection, selectedField: selectedField, onSelectField: handleSelectField, onBack: handleBack, isFocused: focusPane === "editor", validationErrors: validationErrors, maxHeight: editorMaxHeight }) }), _jsx(Preview, { config: config, selectedSection: selectedSection, visible: previewVisible })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [focusPane === "nav" && "j/k: navigate | Enter: select | l/→: editor | Tab: toggle preview", focusPane === "editor" && "j/k: navigate | Enter: edit | J: JSON | h/←: nav | Tab: preview | p: presets", focusPane === "field-editor" && "Follow editor hints", focusPane === "preset-selector" && "j/k: navigate | Enter: select | Esc: back"] }) })] }));
|
|
312
|
+
}
|
|
313
|
+
export default ConfigEditor;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface ArrayEditorProps {
|
|
3
|
+
/** The label to display for this array field */
|
|
4
|
+
label: string;
|
|
5
|
+
/** The current array items */
|
|
6
|
+
items: string[];
|
|
7
|
+
/** Called when the user confirms the edit (Enter) */
|
|
8
|
+
onConfirm: (newItems: string[]) => void;
|
|
9
|
+
/** Called when the user cancels the edit (Esc) */
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
/** Whether this editor has focus */
|
|
12
|
+
isFocused?: boolean;
|
|
13
|
+
/** Maximum height for the items list (for scrolling) */
|
|
14
|
+
maxHeight?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* ArrayEditor component for editing arrays of strings.
|
|
18
|
+
* Shows numbered list with edit/delete options.
|
|
19
|
+
* Supports adding new items, reordering with move up/down keys, and scrolling for long lists.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ArrayEditor({ label, items, onConfirm, onCancel, isFocused, maxHeight, }: ArrayEditorProps): React.ReactElement;
|
|
22
|
+
export default ArrayEditor;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
/**
|
|
6
|
+
* ArrayEditor component for editing arrays of strings.
|
|
7
|
+
* Shows numbered list with edit/delete options.
|
|
8
|
+
* Supports adding new items, reordering with move up/down keys, and scrolling for long lists.
|
|
9
|
+
*/
|
|
10
|
+
export function ArrayEditor({ label, items, onConfirm, onCancel, isFocused = true, maxHeight = 10, }) {
|
|
11
|
+
const [editItems, setEditItems] = useState([...items]);
|
|
12
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
13
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
14
|
+
const [mode, setMode] = useState("list");
|
|
15
|
+
const [editText, setEditText] = useState("");
|
|
16
|
+
const [editingIndex, setEditingIndex] = useState(-1);
|
|
17
|
+
// Total items including "+ Add item" option
|
|
18
|
+
const totalOptions = editItems.length + 1;
|
|
19
|
+
// Auto-scroll to keep highlighted item visible
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (highlightedIndex < scrollOffset) {
|
|
22
|
+
setScrollOffset(highlightedIndex);
|
|
23
|
+
}
|
|
24
|
+
else if (highlightedIndex >= scrollOffset + maxHeight) {
|
|
25
|
+
setScrollOffset(highlightedIndex - maxHeight + 1);
|
|
26
|
+
}
|
|
27
|
+
}, [highlightedIndex, scrollOffset, maxHeight]);
|
|
28
|
+
// Navigation handlers
|
|
29
|
+
const handleNavigateUp = useCallback(() => {
|
|
30
|
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalOptions - 1));
|
|
31
|
+
}, [totalOptions]);
|
|
32
|
+
const handleNavigateDown = useCallback(() => {
|
|
33
|
+
setHighlightedIndex((prev) => (prev < totalOptions - 1 ? prev + 1 : 0));
|
|
34
|
+
}, [totalOptions]);
|
|
35
|
+
const handlePageUp = useCallback(() => {
|
|
36
|
+
const newIndex = Math.max(0, highlightedIndex - maxHeight);
|
|
37
|
+
setHighlightedIndex(newIndex);
|
|
38
|
+
}, [highlightedIndex, maxHeight]);
|
|
39
|
+
const handlePageDown = useCallback(() => {
|
|
40
|
+
const newIndex = Math.min(totalOptions - 1, highlightedIndex + maxHeight);
|
|
41
|
+
setHighlightedIndex(newIndex);
|
|
42
|
+
}, [highlightedIndex, maxHeight, totalOptions]);
|
|
43
|
+
// Move item up in the list
|
|
44
|
+
const handleMoveUp = useCallback(() => {
|
|
45
|
+
if (highlightedIndex > 0 && highlightedIndex < editItems.length) {
|
|
46
|
+
const newItems = [...editItems];
|
|
47
|
+
const temp = newItems[highlightedIndex - 1];
|
|
48
|
+
newItems[highlightedIndex - 1] = newItems[highlightedIndex];
|
|
49
|
+
newItems[highlightedIndex] = temp;
|
|
50
|
+
setEditItems(newItems);
|
|
51
|
+
setHighlightedIndex(highlightedIndex - 1);
|
|
52
|
+
}
|
|
53
|
+
}, [highlightedIndex, editItems]);
|
|
54
|
+
// Move item down in the list
|
|
55
|
+
const handleMoveDown = useCallback(() => {
|
|
56
|
+
if (highlightedIndex < editItems.length - 1) {
|
|
57
|
+
const newItems = [...editItems];
|
|
58
|
+
const temp = newItems[highlightedIndex + 1];
|
|
59
|
+
newItems[highlightedIndex + 1] = newItems[highlightedIndex];
|
|
60
|
+
newItems[highlightedIndex] = temp;
|
|
61
|
+
setEditItems(newItems);
|
|
62
|
+
setHighlightedIndex(highlightedIndex + 1);
|
|
63
|
+
}
|
|
64
|
+
}, [highlightedIndex, editItems]);
|
|
65
|
+
// Delete the highlighted item
|
|
66
|
+
const handleDelete = useCallback(() => {
|
|
67
|
+
if (highlightedIndex < editItems.length) {
|
|
68
|
+
const newItems = editItems.filter((_, i) => i !== highlightedIndex);
|
|
69
|
+
setEditItems(newItems);
|
|
70
|
+
// Adjust highlighted index if needed
|
|
71
|
+
if (highlightedIndex >= newItems.length && newItems.length > 0) {
|
|
72
|
+
setHighlightedIndex(newItems.length - 1);
|
|
73
|
+
}
|
|
74
|
+
else if (newItems.length === 0) {
|
|
75
|
+
setHighlightedIndex(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [highlightedIndex, editItems]);
|
|
79
|
+
// Start editing an item
|
|
80
|
+
const handleStartEdit = useCallback(() => {
|
|
81
|
+
if (highlightedIndex < editItems.length) {
|
|
82
|
+
setEditText(editItems[highlightedIndex]);
|
|
83
|
+
setEditingIndex(highlightedIndex);
|
|
84
|
+
setMode("edit");
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Add new item
|
|
88
|
+
setEditText("");
|
|
89
|
+
setMode("add");
|
|
90
|
+
}
|
|
91
|
+
}, [highlightedIndex, editItems]);
|
|
92
|
+
// Confirm edit/add
|
|
93
|
+
const handleTextSubmit = useCallback(() => {
|
|
94
|
+
const trimmedText = editText.trim();
|
|
95
|
+
if (trimmedText) {
|
|
96
|
+
if (mode === "add") {
|
|
97
|
+
setEditItems([...editItems, trimmedText]);
|
|
98
|
+
setHighlightedIndex(editItems.length);
|
|
99
|
+
}
|
|
100
|
+
else if (mode === "edit") {
|
|
101
|
+
const newItems = [...editItems];
|
|
102
|
+
newItems[editingIndex] = trimmedText;
|
|
103
|
+
setEditItems(newItems);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
setMode("list");
|
|
107
|
+
setEditText("");
|
|
108
|
+
setEditingIndex(-1);
|
|
109
|
+
}, [editText, mode, editItems, editingIndex]);
|
|
110
|
+
// Cancel edit/add
|
|
111
|
+
const handleTextCancel = useCallback(() => {
|
|
112
|
+
setMode("list");
|
|
113
|
+
setEditText("");
|
|
114
|
+
setEditingIndex(-1);
|
|
115
|
+
}, []);
|
|
116
|
+
// Handle keyboard input for list mode
|
|
117
|
+
useInput((input, key) => {
|
|
118
|
+
if (!isFocused || mode !== "list")
|
|
119
|
+
return;
|
|
120
|
+
// j/k or arrow keys for navigation
|
|
121
|
+
if (input === "j" || key.downArrow) {
|
|
122
|
+
handleNavigateDown();
|
|
123
|
+
}
|
|
124
|
+
else if (input === "k" || key.upArrow) {
|
|
125
|
+
handleNavigateUp();
|
|
126
|
+
}
|
|
127
|
+
else if (key.pageUp) {
|
|
128
|
+
handlePageUp();
|
|
129
|
+
}
|
|
130
|
+
else if (key.pageDown) {
|
|
131
|
+
handlePageDown();
|
|
132
|
+
}
|
|
133
|
+
else if (key.return || input === "e") {
|
|
134
|
+
// Enter or 'e' to edit/add
|
|
135
|
+
handleStartEdit();
|
|
136
|
+
}
|
|
137
|
+
else if (input === "d" || key.delete) {
|
|
138
|
+
// 'd' or Delete to remove
|
|
139
|
+
handleDelete();
|
|
140
|
+
}
|
|
141
|
+
else if (input === "K" || (key.shift && key.upArrow)) {
|
|
142
|
+
// Shift+K or Shift+Up to move up
|
|
143
|
+
handleMoveUp();
|
|
144
|
+
}
|
|
145
|
+
else if (input === "J" || (key.shift && key.downArrow)) {
|
|
146
|
+
// Shift+J or Shift+Down to move down
|
|
147
|
+
handleMoveDown();
|
|
148
|
+
}
|
|
149
|
+
else if (key.escape) {
|
|
150
|
+
onCancel();
|
|
151
|
+
}
|
|
152
|
+
else if (input === "s" || input === "S") {
|
|
153
|
+
// 's' to save and confirm
|
|
154
|
+
onConfirm(editItems);
|
|
155
|
+
}
|
|
156
|
+
}, { isActive: isFocused && mode === "list" });
|
|
157
|
+
// Handle keyboard input for text editing mode
|
|
158
|
+
useInput((_input, key) => {
|
|
159
|
+
if (!isFocused || mode === "list")
|
|
160
|
+
return;
|
|
161
|
+
if (key.escape) {
|
|
162
|
+
handleTextCancel();
|
|
163
|
+
}
|
|
164
|
+
}, { isActive: isFocused && mode !== "list" });
|
|
165
|
+
// Calculate visible items based on scroll offset
|
|
166
|
+
const visibleItems = useMemo(() => {
|
|
167
|
+
const allItems = [...editItems, { isAddOption: true }];
|
|
168
|
+
const endIndex = Math.min(scrollOffset + maxHeight, allItems.length);
|
|
169
|
+
return allItems.slice(scrollOffset, endIndex);
|
|
170
|
+
}, [editItems, scrollOffset, maxHeight]);
|
|
171
|
+
// Check if we have overflow
|
|
172
|
+
const canScrollUp = scrollOffset > 0;
|
|
173
|
+
const canScrollDown = scrollOffset + maxHeight < totalOptions;
|
|
174
|
+
const hasOverflow = totalOptions > maxHeight;
|
|
175
|
+
// Render text input mode (add or edit)
|
|
176
|
+
if (mode === "add" || mode === "edit") {
|
|
177
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: mode === "add" ? "Add New Item" : `Edit Item ${editingIndex + 1}` }) }), _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [">", " "] }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: confirm | Esc: cancel" }) })] }));
|
|
178
|
+
}
|
|
179
|
+
// Render list mode
|
|
180
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Edit: ", label] }), _jsxs(Text, { dimColor: true, children: [" (", editItems.length, " items)"] }), hasOverflow && (_jsxs(Text, { dimColor: true, children: [" [", highlightedIndex + 1, "/", totalOptions, "]"] }))] }), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollUp ? "cyan" : "gray", dimColor: !canScrollUp, children: canScrollUp ? " ▲ more" : "" }) })), editItems.length === 0 && scrollOffset === 0 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "No items" }) })) : (visibleItems.map((item, visibleIndex) => {
|
|
181
|
+
// Check if this is the "Add item" option
|
|
182
|
+
if (typeof item === "object" && "isAddOption" in item) {
|
|
183
|
+
const actualIndex = editItems.length;
|
|
184
|
+
const isHighlighted = actualIndex === highlightedIndex;
|
|
185
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "green" : undefined, children: isHighlighted ? "▸ " : " " }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "green" : "gray", inverse: isHighlighted, children: "+ Add item" })] }, "add-item"));
|
|
186
|
+
}
|
|
187
|
+
// Regular item
|
|
188
|
+
const actualIndex = scrollOffset + visibleIndex;
|
|
189
|
+
const isHighlighted = actualIndex === highlightedIndex;
|
|
190
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "cyan" : undefined, children: isHighlighted ? "▸ " : " " }), _jsxs(Text, { dimColor: true, children: [String(actualIndex + 1).padStart(2, " "), ". "] }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "cyan" : undefined, inverse: isHighlighted, children: item })] }, `item-${actualIndex}`));
|
|
191
|
+
})), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollDown ? "cyan" : "gray", dimColor: !canScrollDown, children: canScrollDown ? " ▼ more" : "" }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "j/k: navigate | Enter/e: edit | d: delete" }), _jsxs(Text, { dimColor: true, children: ["J/K: reorder | s: save | Esc: cancel", hasOverflow && " | PgUp/Dn: scroll"] })] })] }));
|
|
192
|
+
}
|
|
193
|
+
export default ArrayEditor;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface BooleanToggleProps {
|
|
3
|
+
/** The label to display for this field */
|
|
4
|
+
label: string;
|
|
5
|
+
/** The current value of the boolean */
|
|
6
|
+
value: boolean;
|
|
7
|
+
/** Called when the user confirms the edit (Enter) */
|
|
8
|
+
onConfirm: (newValue: boolean) => void;
|
|
9
|
+
/** Called when the user cancels the edit (Esc) */
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
/** Whether this editor has focus */
|
|
12
|
+
isFocused?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* BooleanToggle component provides a toggle UI for boolean fields.
|
|
16
|
+
* Space or arrow keys to toggle, Enter to confirm, Esc to cancel.
|
|
17
|
+
*/
|
|
18
|
+
export declare function BooleanToggle({ label, value, onConfirm, onCancel, isFocused, }: BooleanToggleProps): React.ReactElement;
|
|
19
|
+
export default BooleanToggle;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
/**
|
|
5
|
+
* BooleanToggle component provides a toggle UI for boolean fields.
|
|
6
|
+
* Space or arrow keys to toggle, Enter to confirm, Esc to cancel.
|
|
7
|
+
*/
|
|
8
|
+
export function BooleanToggle({ label, value, onConfirm, onCancel, isFocused = true, }) {
|
|
9
|
+
const [editValue, setEditValue] = useState(value);
|
|
10
|
+
// Toggle the value
|
|
11
|
+
const handleToggle = useCallback(() => {
|
|
12
|
+
setEditValue((prev) => !prev);
|
|
13
|
+
}, []);
|
|
14
|
+
// Confirm the value
|
|
15
|
+
const handleConfirm = useCallback(() => {
|
|
16
|
+
onConfirm(editValue);
|
|
17
|
+
}, [editValue, onConfirm]);
|
|
18
|
+
// Handle keyboard input
|
|
19
|
+
useInput((input, key) => {
|
|
20
|
+
if (!isFocused)
|
|
21
|
+
return;
|
|
22
|
+
// Space, left/right arrows, or 't'/'f' to toggle
|
|
23
|
+
if (input === " " || key.leftArrow || key.rightArrow || input === "t" || input === "f") {
|
|
24
|
+
if (input === "t") {
|
|
25
|
+
setEditValue(true);
|
|
26
|
+
}
|
|
27
|
+
else if (input === "f") {
|
|
28
|
+
setEditValue(false);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
handleToggle();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (key.return) {
|
|
35
|
+
handleConfirm();
|
|
36
|
+
}
|
|
37
|
+
else if (key.escape) {
|
|
38
|
+
onCancel();
|
|
39
|
+
}
|
|
40
|
+
}, { isActive: isFocused });
|
|
41
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["Edit: ", label] }) }), _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [">", " "] }), _jsxs(Box, { marginRight: 2, children: [_jsx(Text, { color: !editValue ? "red" : undefined, bold: !editValue, inverse: !editValue, children: !editValue ? " ● " : " ○ " }), _jsx(Text, { color: !editValue ? "red" : "gray", bold: !editValue, children: "false" })] }), _jsxs(Box, { children: [_jsx(Text, { color: editValue ? "green" : undefined, bold: editValue, inverse: editValue, children: editValue ? " ● " : " ○ " }), _jsx(Text, { color: editValue ? "green" : "gray", bold: editValue, children: "true" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Space/\u2190/\u2192: toggle | t/f: set | Enter: confirm | Esc: cancel" }) })] }));
|
|
42
|
+
}
|
|
43
|
+
export default BooleanToggle;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { RalphConfig } from "../../utils/config.js";
|
|
3
|
+
import type { ValidationError } from "../utils/validation.js";
|
|
4
|
+
/**
|
|
5
|
+
* Field types for determining which editor to render.
|
|
6
|
+
*/
|
|
7
|
+
export type FieldType = "string" | "boolean" | "number" | "array" | "object" | "unknown";
|
|
8
|
+
/**
|
|
9
|
+
* Field schema describes a configuration field for editing.
|
|
10
|
+
*/
|
|
11
|
+
export interface FieldSchema {
|
|
12
|
+
path: string;
|
|
13
|
+
label: string;
|
|
14
|
+
type: FieldType;
|
|
15
|
+
description?: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get the value at a dot-notation path from an object.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getValueAtPath(obj: unknown, path: string): unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Determine the type of a field based on its current value.
|
|
24
|
+
*/
|
|
25
|
+
export declare function inferFieldType(value: unknown): FieldType;
|
|
26
|
+
export interface EditorPanelProps {
|
|
27
|
+
/** The current configuration */
|
|
28
|
+
config: RalphConfig | null;
|
|
29
|
+
/** Currently selected section ID */
|
|
30
|
+
selectedSection: string;
|
|
31
|
+
/** Currently selected field path (for nested editing) */
|
|
32
|
+
selectedField?: string;
|
|
33
|
+
/** Callback when a field is selected for editing (useJsonEditor=true for J shortcut) */
|
|
34
|
+
onSelectField: (fieldPath: string, useJsonEditor?: boolean) => void;
|
|
35
|
+
/** Callback when navigating back (Esc) */
|
|
36
|
+
onBack: () => void;
|
|
37
|
+
/** Whether this component has focus for keyboard input */
|
|
38
|
+
isFocused?: boolean;
|
|
39
|
+
/** Validation errors to display inline */
|
|
40
|
+
validationErrors?: ValidationError[];
|
|
41
|
+
/** Maximum height for the fields list (for scrolling) */
|
|
42
|
+
maxHeight?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* EditorPanel component displays fields for the selected config section.
|
|
46
|
+
* It shows a breadcrumb of the current path and lists editable fields.
|
|
47
|
+
* Supports scrolling for long content with Page Up/Down and scroll indicators.
|
|
48
|
+
*/
|
|
49
|
+
export declare function EditorPanel({ config, selectedSection, selectedField, onSelectField, onBack, isFocused, validationErrors, maxHeight, }: EditorPanelProps): React.ReactElement;
|
|
50
|
+
export default EditorPanel;
|