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.
Files changed (74) hide show
  1. package/.~lock.ROADMAP.md# +1 -0
  2. package/CHANGELOG.md +51 -0
  3. package/README.es.md +198 -0
  4. package/README.md +81 -23
  5. package/ROADMAP.md +647 -0
  6. package/coverage/clover.xml +400 -519
  7. package/coverage/coverage-final.json +16 -20
  8. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  9. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  10. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +31 -13
  11. package/coverage/lcov-report/components/PromptField.jsx.html +4 -4
  12. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +37 -13
  13. package/coverage/lcov-report/components/index.html +14 -14
  14. package/coverage/lcov-report/helpers/constants.js.html +87 -33
  15. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +40 -40
  16. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +1 -1
  18. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +1 -1
  19. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +1 -1
  20. package/coverage/lcov-report/helpers/imageNameUtils.js.html +18 -18
  21. package/coverage/lcov-report/helpers/index.html +77 -62
  22. package/coverage/lcov-report/helpers/logger.js.html +60 -60
  23. package/coverage/lcov-report/helpers/safeCall.js.html +14 -14
  24. package/coverage/lcov-report/helpers/validationHelpers.js.html +52 -52
  25. package/coverage/lcov-report/hooks/creation/index.html +19 -19
  26. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +4 -4
  27. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +225 -48
  28. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +5 -5
  29. package/coverage/lcov-report/hooks/debug/index.html +1 -1
  30. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +7 -7
  31. package/coverage/lcov-report/hooks/index.html +15 -15
  32. package/coverage/lcov-report/hooks/navigation/index.html +1 -1
  33. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +6 -6
  34. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +13 -13
  35. package/coverage/lcov-report/hooks/useControls.js.html +82 -40
  36. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +5 -5
  37. package/coverage/lcov-report/hooks/useExitHandler.js.html +6 -6
  38. package/coverage/lcov-report/index.html +38 -68
  39. package/coverage/lcov.info +742 -981
  40. package/dist/App.js +7 -1
  41. package/dist/components/ContainerCreationPrompt.js +26 -10
  42. package/dist/components/ControlsHUD.js +106 -0
  43. package/dist/components/SuggestionPanel.js +16 -7
  44. package/dist/helpers/constants.js +60 -21
  45. package/dist/helpers/dockerHubService.js +111 -0
  46. package/dist/helpers/logger.js +1 -1
  47. package/dist/hooks/creation/useContainerActions.js +22 -5
  48. package/dist/hooks/creation/useContainerCreation.js +395 -107
  49. package/dist/hooks/useContainerCommandRouter.js +1 -1
  50. package/dist/hooks/useControls.js +23 -7
  51. package/dist/index.js +7 -0
  52. package/package.json +1 -1
  53. package/src/App.jsx +6 -0
  54. package/src/components/ContainerCreationPrompt.jsx +15 -5
  55. package/src/components/ControlsHUD.jsx +66 -0
  56. package/src/components/SuggestionPanel.jsx +10 -2
  57. package/src/helpers/constants.js +38 -20
  58. package/src/helpers/dockerHubService.js +75 -0
  59. package/src/helpers/logger.js +1 -2
  60. package/src/hooks/creation/useContainerActions.js +19 -6
  61. package/src/hooks/creation/useContainerCreation.js +224 -87
  62. package/src/hooks/useContainerCommandRouter.js +1 -1
  63. package/src/hooks/useControls.js +24 -8
  64. package/src/index.js +4 -0
  65. package/test/ContainerCreationPrompt.dom.test.js +245 -0
  66. package/test/ControlsHUD.dom.test.js +125 -0
  67. package/test/SuggestionPanel.dom.test.js +30 -0
  68. package/test/constants.test.js +7 -0
  69. package/test/dockerHubService.test.js +213 -0
  70. package/test/logger.test.js +58 -0
  71. package/test/resolveImageTag.test.js +26 -0
  72. package/test/useContainerCreation.dom.test.js +306 -3
  73. package/test/useContainerCreationHub.dom.test.js +184 -0
  74. 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 [step, setStep] = useState(0); // 0: image, 1: name, 2: ports, 3: env
29
- const [imageName, setImageName] = useState('');
30
- const [containerName, setContainerName] = useState('');
31
- const [portInput, setPortInput] = useState('');
32
- const [envInput, setEnvInput] = useState('');
33
- const [message, setMessage] = useState('');
34
- const [messageColor, setMessageColor] = useState('yellow');
35
-
36
- // Autocomplete state
37
- const [suggestions, setSuggestions] = useState([]);
38
- const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
39
- const [visibleOffset, setVisibleOffset] = useState(0);
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
- setImageName(value);
49
- setSelectedSuggestionIndex(-1);
50
- setVisibleOffset(0);
51
- if (!value.trim()) {
52
- setSuggestions([]);
53
- return;
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 = Object.keys(imageProfiles).filter((key) =>
57
- key.includes(lower)
58
- );
59
- setSuggestions(matches);
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
- setSelectedSuggestionIndex((prev) => {
71
- const next = Math.max(-1, Math.min(suggestions.length - 1, prev + direction));
72
- // Adjust visibleOffset to keep selected in view
73
- setVisibleOffset((offset) => {
74
- if (next < offset) return Math.max(0, next);
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
- setImageName(chosen);
90
- setSuggestions([]);
91
- setSelectedSuggestionIndex(-1);
92
- setVisibleOffset(0);
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
- setMessage('Image name cannot be empty.');
102
- setMessageColor('red');
229
+ setTimedMessage('Image name cannot be empty.', 'red');
103
230
  return;
104
231
  }
105
- setStep(1);
106
- setMessage(
107
- 'Optional: Enter container name or leave empty and press Enter'
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
- setStep(2);
114
- setMessage(
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
- setMessage(
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
- setStep(3);
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
- setMessage(
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
- setMessage(
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
- setMessage(
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
- setMessage(
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
- setMessage(result.errors.join(' | '));
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
- setStep(0);
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
- setStep(0);
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
  }
@@ -94,8 +94,8 @@ export function useContainerCommandRouter({
94
94
  }
95
95
 
96
96
  if (input === 'c') {
97
- onStartCreate();
98
97
  creation.resetCreation();
98
+ onStartCreate();
99
99
  return true;
100
100
  }
101
101
 
@@ -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.setMessage(`Creating container ${imageName}...`);
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.setMessage(`Created container ${id}${portMsg}`);
51
- actions.setMessageColor('green');
49
+ actions.setTimedMessage(`Created container ${id}${portMsg}`, 'green');
52
50
  } catch (err) {
53
- actions.setMessage(`Error creating container: ${err.message}`);
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 />);