cdd-cli 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.~lock.ROADMAP.md# +1 -0
- package/CHANGELOG.md +51 -0
- package/README.es.md +198 -0
- package/README.md +81 -23
- package/ROADMAP.md +647 -0
- package/coverage/clover.xml +400 -519
- package/coverage/coverage-final.json +16 -20
- package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
- package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +31 -13
- package/coverage/lcov-report/components/PromptField.jsx.html +4 -4
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +37 -13
- package/coverage/lcov-report/components/index.html +14 -14
- package/coverage/lcov-report/helpers/constants.js.html +87 -33
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +40 -40
- package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +1 -1
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +1 -1
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +1 -1
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +18 -18
- package/coverage/lcov-report/helpers/index.html +77 -62
- package/coverage/lcov-report/helpers/logger.js.html +60 -60
- package/coverage/lcov-report/helpers/safeCall.js.html +14 -14
- package/coverage/lcov-report/helpers/validationHelpers.js.html +52 -52
- package/coverage/lcov-report/hooks/creation/index.html +19 -19
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +4 -4
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +225 -48
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +5 -5
- package/coverage/lcov-report/hooks/debug/index.html +1 -1
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +7 -7
- package/coverage/lcov-report/hooks/index.html +15 -15
- package/coverage/lcov-report/hooks/navigation/index.html +1 -1
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +6 -6
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +13 -13
- package/coverage/lcov-report/hooks/useControls.js.html +82 -40
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +5 -5
- package/coverage/lcov-report/hooks/useExitHandler.js.html +6 -6
- package/coverage/lcov-report/index.html +38 -68
- package/coverage/lcov.info +742 -981
- package/dist/App.js +7 -1
- package/dist/components/ContainerCreationPrompt.js +26 -10
- package/dist/components/ControlsHUD.js +106 -0
- package/dist/components/SuggestionPanel.js +16 -7
- package/dist/helpers/constants.js +60 -21
- package/dist/helpers/dockerHubService.js +111 -0
- package/dist/helpers/logger.js +1 -1
- package/dist/hooks/creation/useContainerActions.js +22 -5
- package/dist/hooks/creation/useContainerCreation.js +395 -107
- package/dist/hooks/useContainerCommandRouter.js +1 -1
- package/dist/hooks/useControls.js +23 -7
- package/dist/index.js +7 -0
- package/package.json +1 -1
- package/src/App.jsx +6 -0
- package/src/components/ContainerCreationPrompt.jsx +15 -5
- package/src/components/ControlsHUD.jsx +66 -0
- package/src/components/SuggestionPanel.jsx +10 -2
- package/src/helpers/constants.js +38 -20
- package/src/helpers/dockerHubService.js +75 -0
- package/src/helpers/logger.js +1 -2
- package/src/hooks/creation/useContainerActions.js +19 -6
- package/src/hooks/creation/useContainerCreation.js +224 -87
- package/src/hooks/useContainerCommandRouter.js +1 -1
- package/src/hooks/useControls.js +24 -8
- package/src/index.js +4 -0
- package/test/ContainerCreationPrompt.dom.test.js +245 -0
- package/test/ControlsHUD.dom.test.js +125 -0
- package/test/SuggestionPanel.dom.test.js +30 -0
- package/test/constants.test.js +7 -0
- package/test/dockerHubService.test.js +213 -0
- package/test/logger.test.js +58 -0
- package/test/resolveImageTag.test.js +26 -0
- package/test/useContainerCreation.dom.test.js +306 -3
- package/test/useContainerCreationHub.dom.test.js +184 -0
- package/test/useControls.dom.test.js +212 -6
|
@@ -1,13 +1,36 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useReducer, useState, useRef, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
validatePorts,
|
|
4
4
|
validateEnvVars,
|
|
5
5
|
} from '../../helpers/validationHelpers.js';
|
|
6
|
-
import { IMAGE_PROFILES } from '../../helpers/constants.js';
|
|
6
|
+
import { IMAGE_PROFILES, resolveImageTag } from '../../helpers/constants.js';
|
|
7
7
|
import { safeCall } from '../../helpers/safeCall.js';
|
|
8
|
+
import { searchDockerHub, formatHubResult } from '../../helpers/dockerHubService.js';
|
|
8
9
|
|
|
9
10
|
const MAX_VISIBLE = 6;
|
|
10
11
|
|
|
12
|
+
const INITIAL_FORM = {
|
|
13
|
+
step: 0,
|
|
14
|
+
imageName: '',
|
|
15
|
+
containerName: '',
|
|
16
|
+
portInput: '',
|
|
17
|
+
envInput: '',
|
|
18
|
+
message: '',
|
|
19
|
+
messageColor: 'yellow',
|
|
20
|
+
suggestions: [],
|
|
21
|
+
selectedSuggestionIndex: -1,
|
|
22
|
+
visibleOffset: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function formReducer(state, action) {
|
|
26
|
+
switch (action.type) {
|
|
27
|
+
case 'RESET': return { ...INITIAL_FORM };
|
|
28
|
+
case 'RESET_WIZARD': return { ...INITIAL_FORM, message: 'Insert the name of the image to create: ', messageColor: 'yellow' };
|
|
29
|
+
case 'SET': return { ...state, ...action.payload };
|
|
30
|
+
default: return state;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/**
|
|
12
35
|
* Custom hook to manage the container creation flow, step by step.
|
|
13
36
|
* Handles input, validation, and feedback for each creation step.
|
|
@@ -25,38 +48,143 @@ export function useContainerCreation({
|
|
|
25
48
|
dbImages = [],
|
|
26
49
|
imageProfiles = IMAGE_PROFILES,
|
|
27
50
|
}) {
|
|
28
|
-
const [
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
const [form, dispatch] = useReducer(formReducer, INITIAL_FORM);
|
|
52
|
+
const {
|
|
53
|
+
step,
|
|
54
|
+
imageName,
|
|
55
|
+
containerName,
|
|
56
|
+
portInput,
|
|
57
|
+
envInput,
|
|
58
|
+
message,
|
|
59
|
+
messageColor,
|
|
60
|
+
suggestions,
|
|
61
|
+
selectedSuggestionIndex,
|
|
62
|
+
visibleOffset,
|
|
63
|
+
} = form;
|
|
64
|
+
|
|
65
|
+
// Convenience setters that mirror the old useState API
|
|
66
|
+
const setStep = (v) => dispatch({ type: 'SET', payload: { step: v } });
|
|
67
|
+
const setImageName = (v) => dispatch({ type: 'SET', payload: { imageName: v } });
|
|
68
|
+
const setContainerName = (v) => dispatch({ type: 'SET', payload: { containerName: v } });
|
|
69
|
+
const setPortInput = (v) => dispatch({ type: 'SET', payload: { portInput: v } });
|
|
70
|
+
const setEnvInput = (v) =>
|
|
71
|
+
dispatch({ type: 'SET', payload: { envInput: typeof v === 'function' ? v(form.envInput) : v } });
|
|
72
|
+
const setMessage = (v) => dispatch({ type: 'SET', payload: { message: v } });
|
|
73
|
+
const setMessageColor = (v) => dispatch({ type: 'SET', payload: { messageColor: v } });
|
|
74
|
+
const setSuggestions = (v) => dispatch({ type: 'SET', payload: { suggestions: v } });
|
|
75
|
+
const setSelectedSuggestionIndex = (fn) => {
|
|
76
|
+
// Support both direct value and updater function
|
|
77
|
+
if (typeof fn === 'function') {
|
|
78
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: fn(form.selectedSuggestionIndex) } });
|
|
79
|
+
} else {
|
|
80
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: fn } });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const setVisibleOffset = (fn) => {
|
|
84
|
+
if (typeof fn === 'function') {
|
|
85
|
+
dispatch({ type: 'SET', payload: { visibleOffset: fn(form.visibleOffset) } });
|
|
86
|
+
} else {
|
|
87
|
+
dispatch({ type: 'SET', payload: { visibleOffset: fn } });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Docker Hub search state (kept as useState because they are independent of form)
|
|
92
|
+
const [isSearchingHub, setIsSearchingHub] = useState(false);
|
|
93
|
+
const [hubResults, setHubResults] = useState(null);
|
|
94
|
+
// Tracks active request: { controller: AbortController|null, requestId: number }
|
|
95
|
+
const activeHubRequestRef = useRef({ controller: null, requestId: 0 });
|
|
96
|
+
|
|
97
|
+
// Timer for auto-clearing messages
|
|
98
|
+
const messageTimerRef = useRef(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Sets a permanent message (e.g. step instructions). No auto-clear.
|
|
102
|
+
*/
|
|
103
|
+
function setStepMessage(msg, color = 'yellow') {
|
|
104
|
+
clearTimeout(messageTimerRef.current);
|
|
105
|
+
dispatch({ type: 'SET', payload: { message: msg, messageColor: color } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sets a transient feedback message (e.g. validation errors). Auto-clears after `ms`.
|
|
110
|
+
*/
|
|
111
|
+
function setTimedMessage(msg, color = 'yellow', ms = 4000) {
|
|
112
|
+
clearTimeout(messageTimerRef.current);
|
|
113
|
+
dispatch({ type: 'SET', payload: { message: msg, messageColor: color } });
|
|
114
|
+
if (msg) {
|
|
115
|
+
messageTimerRef.current = setTimeout(() => dispatch({ type: 'SET', payload: { message: '' } }), ms);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
useEffect(() => () => clearTimeout(messageTimerRef.current), []);
|
|
40
120
|
|
|
41
121
|
/**
|
|
42
122
|
* Updates the image name input and recalculates autocomplete suggestions.
|
|
43
123
|
* Resets selectedSuggestionIndex to -1 on every keystroke.
|
|
124
|
+
* Also aborts any in-flight Hub search and clears Hub results.
|
|
44
125
|
*
|
|
45
126
|
* @param {string} value - New raw image name typed by user
|
|
46
127
|
*/
|
|
47
128
|
function updateImageInput(value) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
129
|
+
// Abort any in-flight Hub search and clear its state
|
|
130
|
+
activeHubRequestRef.current.controller?.abort();
|
|
131
|
+
activeHubRequestRef.current.requestId += 1;
|
|
132
|
+
setIsSearchingHub(false);
|
|
133
|
+
setHubResults(null);
|
|
134
|
+
|
|
55
135
|
const lower = value.toLowerCase();
|
|
56
|
-
const matches =
|
|
57
|
-
key.includes(lower)
|
|
58
|
-
|
|
59
|
-
|
|
136
|
+
const matches = value.trim()
|
|
137
|
+
? Object.keys(imageProfiles).filter((key) => key.includes(lower))
|
|
138
|
+
: [];
|
|
139
|
+
|
|
140
|
+
dispatch({
|
|
141
|
+
type: 'SET',
|
|
142
|
+
payload: {
|
|
143
|
+
imageName: value,
|
|
144
|
+
selectedSuggestionIndex: -1,
|
|
145
|
+
visibleOffset: 0,
|
|
146
|
+
suggestions: matches,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Triggers a Docker Hub search for the current imageName.
|
|
153
|
+
* Guards: imageName must be non-empty; no concurrent search allowed.
|
|
154
|
+
* Uses AbortController + requestId to handle race conditions.
|
|
155
|
+
*/
|
|
156
|
+
async function triggerHubSearch() {
|
|
157
|
+
const query = imageName.trim();
|
|
158
|
+
if (!query) return;
|
|
159
|
+
if (isSearchingHub) return;
|
|
160
|
+
|
|
161
|
+
// Abort previous request (should already be aborted from updateImageInput,
|
|
162
|
+
// but guard here too)
|
|
163
|
+
activeHubRequestRef.current.controller?.abort();
|
|
164
|
+
|
|
165
|
+
const requestId = activeHubRequestRef.current.requestId + 1;
|
|
166
|
+
activeHubRequestRef.current.requestId = requestId;
|
|
167
|
+
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
activeHubRequestRef.current.controller = controller;
|
|
170
|
+
|
|
171
|
+
setIsSearchingHub(true);
|
|
172
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: -1, visibleOffset: 0 } });
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const results = await searchDockerHub(query, { signal: controller.signal });
|
|
176
|
+
// Ignore stale responses
|
|
177
|
+
if (activeHubRequestRef.current.requestId !== requestId) return;
|
|
178
|
+
setHubResults(results.map(formatHubResult));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (activeHubRequestRef.current.requestId !== requestId) return;
|
|
181
|
+
// Silence AbortError; for all other errors fall back to static suggestions
|
|
182
|
+
setHubResults(null);
|
|
183
|
+
} finally {
|
|
184
|
+
if (activeHubRequestRef.current.requestId === requestId) {
|
|
185
|
+
setIsSearchingHub(false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
60
188
|
}
|
|
61
189
|
|
|
62
190
|
/**
|
|
@@ -67,16 +195,11 @@ export function useContainerCreation({
|
|
|
67
195
|
* @param {number} direction - -1 (up) or 1 (down)
|
|
68
196
|
*/
|
|
69
197
|
function moveSuggestionSelection(direction) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (next >= offset + MAX_VISIBLE) return next - MAX_VISIBLE + 1;
|
|
76
|
-
return offset;
|
|
77
|
-
});
|
|
78
|
-
return next;
|
|
79
|
-
});
|
|
198
|
+
const next = Math.max(-1, Math.min(suggestions.length - 1, selectedSuggestionIndex + direction));
|
|
199
|
+
let newOffset = visibleOffset;
|
|
200
|
+
if (next < visibleOffset) newOffset = Math.max(0, next);
|
|
201
|
+
else if (next >= visibleOffset + MAX_VISIBLE) newOffset = next - MAX_VISIBLE + 1;
|
|
202
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: next, visibleOffset: newOffset } });
|
|
80
203
|
}
|
|
81
204
|
|
|
82
205
|
/**
|
|
@@ -86,10 +209,15 @@ export function useContainerCreation({
|
|
|
86
209
|
function applyFocusedSuggestion() {
|
|
87
210
|
if (selectedSuggestionIndex < 0 || selectedSuggestionIndex >= suggestions.length) return;
|
|
88
211
|
const chosen = suggestions[selectedSuggestionIndex];
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
212
|
+
dispatch({
|
|
213
|
+
type: 'SET',
|
|
214
|
+
payload: {
|
|
215
|
+
imageName: resolveImageTag(chosen, imageProfiles),
|
|
216
|
+
suggestions: [],
|
|
217
|
+
selectedSuggestionIndex: -1,
|
|
218
|
+
visibleOffset: 0,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
93
221
|
}
|
|
94
222
|
|
|
95
223
|
/**
|
|
@@ -98,35 +226,35 @@ export function useContainerCreation({
|
|
|
98
226
|
function nextStep() {
|
|
99
227
|
if (step === 0) {
|
|
100
228
|
if (!imageName.trim()) {
|
|
101
|
-
|
|
102
|
-
setMessageColor('red');
|
|
229
|
+
setTimedMessage('Image name cannot be empty.', 'red');
|
|
103
230
|
return;
|
|
104
231
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
232
|
+
const resolved = resolveImageTag(imageName, imageProfiles);
|
|
233
|
+
dispatch({ type: 'SET', payload: { imageName: resolved, step: 1 } });
|
|
234
|
+
setStepMessage(
|
|
235
|
+
'Optional: Enter container name or leave empty and press Enter',
|
|
236
|
+
'yellow'
|
|
108
237
|
);
|
|
109
|
-
setMessageColor('yellow');
|
|
110
238
|
return;
|
|
111
239
|
}
|
|
112
240
|
if (step === 1) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
'Optional: Enter ports (format 8080:80,443:443) or leave empty and press Enter'
|
|
241
|
+
dispatch({ type: 'SET', payload: { step: 2 } });
|
|
242
|
+
setStepMessage(
|
|
243
|
+
'Optional: Enter ports (format 8080:80,443:443) or leave empty and press Enter',
|
|
244
|
+
'yellow'
|
|
116
245
|
);
|
|
117
|
-
setMessageColor('yellow');
|
|
118
246
|
return;
|
|
119
247
|
}
|
|
120
248
|
if (step === 2) {
|
|
121
249
|
// Ports are now optional - only validate if provided
|
|
122
250
|
if (portInput.trim() && !validatePorts(portInput)) {
|
|
123
|
-
|
|
124
|
-
'Port format must be host:container and both must be numbers (e.g. 8080:80)'
|
|
251
|
+
setTimedMessage(
|
|
252
|
+
'Port format must be host:container and both must be numbers (e.g. 8080:80)',
|
|
253
|
+
'red'
|
|
125
254
|
);
|
|
126
|
-
setMessageColor('red');
|
|
127
255
|
return;
|
|
128
256
|
}
|
|
129
|
-
|
|
257
|
+
dispatch({ type: 'SET', payload: { step: 3 } });
|
|
130
258
|
const isDb = dbImages.some((db) =>
|
|
131
259
|
imageName.trim().toLowerCase().includes(db)
|
|
132
260
|
);
|
|
@@ -143,25 +271,25 @@ export function useContainerCreation({
|
|
|
143
271
|
profile.suggestedEnv && profile.suggestedEnv.length
|
|
144
272
|
? ` | Suggested: ${profile.suggestedEnv.join(', ')}`
|
|
145
273
|
: '';
|
|
146
|
-
|
|
147
|
-
`Required env vars for ${baseName}: ${profile.requiredEnv.join(', ')}. Enter as VAR=val,VAR2=val2${suggestedPart}
|
|
274
|
+
setStepMessage(
|
|
275
|
+
`Required env vars for ${baseName}: ${profile.requiredEnv.join(', ')}. Enter as VAR=val,VAR2=val2${suggestedPart}`,
|
|
276
|
+
'yellow'
|
|
148
277
|
);
|
|
149
|
-
setMessageColor('yellow');
|
|
150
278
|
} else if (profile && profile.suggestedEnv && profile.suggestedEnv.length) {
|
|
151
|
-
|
|
152
|
-
`Suggested env vars for ${baseName}: ${profile.suggestedEnv.join(', ')}. Enter as VAR=val,VAR2=val2 or leave empty
|
|
279
|
+
setStepMessage(
|
|
280
|
+
`Suggested env vars for ${baseName}: ${profile.suggestedEnv.join(', ')}. Enter as VAR=val,VAR2=val2 or leave empty.`,
|
|
281
|
+
'yellow'
|
|
153
282
|
);
|
|
154
|
-
setMessageColor('yellow');
|
|
155
283
|
} else if (isDb) {
|
|
156
|
-
|
|
157
|
-
'Warning: This image usually requires environment variables (e.g. MYSQL_ROOT_PASSWORD=my-secret-pw for MySQL, POSTGRES_PASSWORD=yourpassword for Postgres). Enter them as VAR=val,VAR2=val2 or leave empty and press Enter.'
|
|
284
|
+
setStepMessage(
|
|
285
|
+
'Warning: This image usually requires environment variables (e.g. MYSQL_ROOT_PASSWORD=my-secret-pw for MySQL, POSTGRES_PASSWORD=yourpassword for Postgres). Enter them as VAR=val,VAR2=val2 or leave empty and press Enter.',
|
|
286
|
+
'yellow'
|
|
158
287
|
);
|
|
159
|
-
setMessageColor('yellow');
|
|
160
288
|
} else {
|
|
161
|
-
|
|
162
|
-
'Optional: Enter environment variables (format VAR1=val1,VAR2=val2) or leave empty and press Enter'
|
|
289
|
+
setStepMessage(
|
|
290
|
+
'Optional: Enter environment variables (format VAR1=val1,VAR2=val2) or leave empty and press Enter',
|
|
291
|
+
'yellow'
|
|
163
292
|
);
|
|
164
|
-
setMessageColor('yellow');
|
|
165
293
|
}
|
|
166
294
|
return;
|
|
167
295
|
}
|
|
@@ -169,8 +297,7 @@ export function useContainerCreation({
|
|
|
169
297
|
// Contextual env validation using image profiles
|
|
170
298
|
const result = validateEnvVars(envInput, imageName, imageProfiles);
|
|
171
299
|
if (!result.valid) {
|
|
172
|
-
|
|
173
|
-
setMessageColor('red');
|
|
300
|
+
setTimedMessage(result.errors.join(' | '), 'red');
|
|
174
301
|
return;
|
|
175
302
|
}
|
|
176
303
|
// Final step: call onCreate with all data (safely)
|
|
@@ -182,16 +309,7 @@ export function useContainerCreation({
|
|
|
182
309
|
* Cancels the creation process and resets state.
|
|
183
310
|
*/
|
|
184
311
|
function cancelCreation() {
|
|
185
|
-
|
|
186
|
-
setImageName('');
|
|
187
|
-
setContainerName('');
|
|
188
|
-
setPortInput('');
|
|
189
|
-
setEnvInput('');
|
|
190
|
-
setMessage('');
|
|
191
|
-
setMessageColor('yellow');
|
|
192
|
-
setSuggestions([]);
|
|
193
|
-
setSelectedSuggestionIndex(-1);
|
|
194
|
-
setVisibleOffset(0);
|
|
312
|
+
dispatch({ type: 'RESET' });
|
|
195
313
|
safeCall(onCancel);
|
|
196
314
|
}
|
|
197
315
|
|
|
@@ -200,18 +318,32 @@ export function useContainerCreation({
|
|
|
200
318
|
* Used by the command router when the user presses 'c' to open the creation wizard.
|
|
201
319
|
*/
|
|
202
320
|
function resetCreation() {
|
|
203
|
-
|
|
204
|
-
setImageName('');
|
|
205
|
-
setContainerName('');
|
|
206
|
-
setPortInput('');
|
|
207
|
-
setEnvInput('');
|
|
208
|
-
setMessage('Insert the name of the image to create: ');
|
|
209
|
-
setMessageColor('yellow');
|
|
210
|
-
setSuggestions([]);
|
|
211
|
-
setSelectedSuggestionIndex(-1);
|
|
212
|
-
setVisibleOffset(0);
|
|
321
|
+
dispatch({ type: 'RESET_WIZARD' });
|
|
213
322
|
}
|
|
214
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Inserts the next pending suggested env var into envInput.
|
|
326
|
+
* Derives "next" by comparing keys already in envInput against suggestedEnv.
|
|
327
|
+
* When all suggestions are already present, sets a feedback message.
|
|
328
|
+
*/
|
|
329
|
+
function insertNextSuggestedEnv() {
|
|
330
|
+
const baseName = imageName.trim().toLowerCase().split(':')[0].split('/').pop();
|
|
331
|
+
const profile = imageProfiles[baseName];
|
|
332
|
+
const suggested = profile?.suggestedEnv ?? [];
|
|
333
|
+
if (!suggested.length) return;
|
|
334
|
+
const addedKeys = envInput.split(',').map(s => s.split('=')[0].trim()).filter(Boolean);
|
|
335
|
+
const next = suggested.find(s => !addedKeys.includes(s.split('=')[0]));
|
|
336
|
+
if (!next) { setTimedMessage('All suggested env vars added', 'yellow'); return; }
|
|
337
|
+
const newEnvInput = envInput ? `${envInput},${next}` : next;
|
|
338
|
+
dispatch({ type: 'SET', payload: { envInput: newEnvInput } });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* True when the current image profile has at least one suggestedEnv entry.
|
|
343
|
+
*/
|
|
344
|
+
const baseName = imageName.trim().toLowerCase().split(':')[0].split('/').pop();
|
|
345
|
+
const hasSuggestedEnv = (imageProfiles[baseName]?.suggestedEnv?.length ?? 0) > 0;
|
|
346
|
+
|
|
215
347
|
return {
|
|
216
348
|
step,
|
|
217
349
|
setStep,
|
|
@@ -230,11 +362,16 @@ export function useContainerCreation({
|
|
|
230
362
|
suggestions,
|
|
231
363
|
selectedSuggestionIndex,
|
|
232
364
|
visibleOffset,
|
|
365
|
+
isSearchingHub,
|
|
366
|
+
hubResults,
|
|
233
367
|
updateImageInput,
|
|
368
|
+
triggerHubSearch,
|
|
234
369
|
moveSuggestionSelection,
|
|
235
370
|
applyFocusedSuggestion,
|
|
236
371
|
nextStep,
|
|
237
372
|
cancelCreation,
|
|
238
373
|
resetCreation,
|
|
374
|
+
insertNextSuggestedEnv,
|
|
375
|
+
hasSuggestedEnv,
|
|
239
376
|
};
|
|
240
377
|
}
|
package/src/hooks/useControls.js
CHANGED
|
@@ -21,7 +21,7 @@ import { DB_IMAGES } from '../helpers/constants.js';
|
|
|
21
21
|
* @param {Array<Object>} containers - Current list of Docker containers
|
|
22
22
|
* @returns {Object} controls - API for the App component
|
|
23
23
|
*/
|
|
24
|
-
export function useControls(containers = []) {
|
|
24
|
+
export function useControls(containers = [], overrides = {}) {
|
|
25
25
|
const [creatingContainer, setCreatingContainer] = React.useState(false);
|
|
26
26
|
|
|
27
27
|
// — Modular hooks —
|
|
@@ -35,8 +35,7 @@ export function useControls(containers = []) {
|
|
|
35
35
|
portInput,
|
|
36
36
|
envInput,
|
|
37
37
|
});
|
|
38
|
-
actions.
|
|
39
|
-
actions.setMessageColor('yellow');
|
|
38
|
+
actions.setTimedMessage(`Creating container ${imageName}...`, 'yellow');
|
|
40
39
|
try {
|
|
41
40
|
const { id, ports } = await svcCreateContainer(imageName, options);
|
|
42
41
|
let portMsg = '';
|
|
@@ -47,11 +46,9 @@ export function useControls(containers = []) {
|
|
|
47
46
|
.map((p) => `${p.hostPort}→${p.containerPort}/${p.protocol}`)
|
|
48
47
|
.join(', ');
|
|
49
48
|
}
|
|
50
|
-
actions.
|
|
51
|
-
actions.setMessageColor('green');
|
|
49
|
+
actions.setTimedMessage(`Created container ${id}${portMsg}`, 'green');
|
|
52
50
|
} catch (err) {
|
|
53
|
-
actions.
|
|
54
|
-
actions.setMessageColor('red');
|
|
51
|
+
actions.setTimedMessage(`Error creating container: ${err.message}`, 'red');
|
|
55
52
|
} finally {
|
|
56
53
|
setCreatingContainer(false);
|
|
57
54
|
}
|
|
@@ -64,6 +61,13 @@ export function useControls(containers = []) {
|
|
|
64
61
|
const selection = useContainerSelection(containers.length);
|
|
65
62
|
const debugLogs = useDebugLogs();
|
|
66
63
|
|
|
64
|
+
// Allow overrides for testability (e.g. injecting a mock triggerHubSearch)
|
|
65
|
+
const triggerHubSearch = overrides.triggerHubSearch ?? creation.triggerHubSearch;
|
|
66
|
+
const isSearchingHub = overrides.isSearchingHub ?? creation.isSearchingHub;
|
|
67
|
+
const insertNextSuggestedEnv = overrides.insertNextSuggestedEnv !== undefined
|
|
68
|
+
? overrides.insertNextSuggestedEnv
|
|
69
|
+
: creation.insertNextSuggestedEnv;
|
|
70
|
+
|
|
67
71
|
const eraseConfirmation = useEraseConfirmation({
|
|
68
72
|
onConfirm: () => {
|
|
69
73
|
actions.handleAction({
|
|
@@ -154,6 +158,18 @@ export function useControls(containers = []) {
|
|
|
154
158
|
return;
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
// Tab on step 0: trigger Docker Hub search (with guards)
|
|
162
|
+
if (key.tab) {
|
|
163
|
+
if (step === 0) {
|
|
164
|
+
if (!isSearchingHub && (creation.imageName || '').trim() !== '') {
|
|
165
|
+
triggerHubSearch();
|
|
166
|
+
}
|
|
167
|
+
} else if (step === 3) {
|
|
168
|
+
insertNextSuggestedEnv?.();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
// Arrow keys on step 0: navigate suggestion list
|
|
158
174
|
if (step === 0 && creation.suggestions.length > 0) {
|
|
159
175
|
if (key.upArrow) {
|
|
@@ -189,7 +205,7 @@ export function useControls(containers = []) {
|
|
|
189
205
|
appendChar(creation.setEnvInput, creation.envInput, input);
|
|
190
206
|
}
|
|
191
207
|
},
|
|
192
|
-
[creation, setCreatingContainer]
|
|
208
|
+
[creation, setCreatingContainer, triggerHubSearch, isSearchingHub, insertNextSuggestedEnv]
|
|
193
209
|
);
|
|
194
210
|
|
|
195
211
|
// Single keyboard entry point
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
import React from 'react';
|
|
12
12
|
import { render } from 'ink';
|
|
13
13
|
import App from './App.jsx';
|
|
14
|
+
import { logger } from './helpers/logger.js';
|
|
15
|
+
|
|
16
|
+
process.on('uncaughtException', (err) => logger.error('Uncaught exception', err));
|
|
17
|
+
process.on('unhandledRejection', (reason) => logger.error('Unhandled rejection', reason));
|
|
14
18
|
|
|
15
19
|
console.clear();
|
|
16
20
|
render(<App />);
|