cdd-cli 3.2.0 → 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 (136) hide show
  1. package/.atl/skill-registry.md +30 -0
  2. package/.editorconfig +12 -0
  3. package/.eslintrc.json +1 -1
  4. package/.prettierignore +4 -0
  5. package/.prettierrc +8 -0
  6. package/.~lock.ROADMAP.md# +1 -0
  7. package/CHANGELOG.md +80 -0
  8. package/README.es.md +198 -0
  9. package/README.md +81 -23
  10. package/ROADMAP.md +647 -0
  11. package/SECURITY.md +5 -0
  12. package/__mocks__/ink.cjs +17 -0
  13. package/coverage/clover.xml +471 -500
  14. package/coverage/coverage-final.json +16 -16
  15. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  16. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  17. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +394 -0
  18. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  19. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +268 -0
  20. package/coverage/lcov-report/components/index.html +146 -0
  21. package/coverage/lcov-report/helpers/constants.js.html +139 -22
  22. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +75 -42
  23. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  24. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
  25. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
  26. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
  27. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  28. package/coverage/lcov-report/helpers/index.html +84 -54
  29. package/coverage/lcov-report/helpers/logger.js.html +68 -68
  30. package/coverage/lcov-report/helpers/safeCall.js.html +15 -15
  31. package/coverage/lcov-report/helpers/validationHelpers.js.html +66 -114
  32. package/coverage/lcov-report/hooks/creation/index.html +20 -20
  33. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
  34. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +593 -89
  35. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
  36. package/coverage/lcov-report/hooks/debug/index.html +5 -5
  37. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
  38. package/coverage/lcov-report/hooks/index.html +40 -40
  39. package/coverage/lcov-report/hooks/navigation/index.html +19 -19
  40. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
  41. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
  42. package/coverage/lcov-report/hooks/useControls.js.html +306 -123
  43. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
  44. package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
  45. package/coverage/lcov-report/index.html +52 -67
  46. package/coverage/lcov.info +899 -952
  47. package/dist/App.js +18 -12
  48. package/dist/components/ContainerCreationPrompt.js +50 -13
  49. package/dist/components/ContainerList.js +3 -3
  50. package/dist/components/ContainerRow.js +20 -20
  51. package/dist/components/ContainerSection.js +3 -3
  52. package/dist/components/ControlsHUD.js +106 -0
  53. package/dist/components/Footer.js +2 -2
  54. package/dist/components/Header.js +7 -7
  55. package/dist/components/LogViewer.js +3 -3
  56. package/dist/components/MessageFeedback.js +2 -2
  57. package/dist/components/PromptField.js +4 -4
  58. package/dist/components/StatsBar.js +5 -5
  59. package/dist/components/SuggestionPanel.js +53 -0
  60. package/dist/components/UsageMenu.js +3 -3
  61. package/dist/helpers/appInfo.js +7 -7
  62. package/dist/helpers/constants.js +154 -1
  63. package/dist/helpers/containerOptionsBuilder.js +3 -3
  64. package/dist/helpers/dockerHubService.js +111 -0
  65. package/dist/helpers/dockerService/dockerService.js +1 -1
  66. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  67. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  68. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  69. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  70. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  71. package/dist/helpers/exitWithMessage.js +10 -10
  72. package/dist/helpers/imageNameUtils.js +32 -0
  73. package/dist/helpers/logger.js +13 -13
  74. package/dist/helpers/safeCall.js +2 -2
  75. package/dist/helpers/validationHelpers.js +76 -21
  76. package/dist/hooks/creation/useContainerActions.js +28 -11
  77. package/dist/hooks/creation/useContainerCreation.js +452 -62
  78. package/dist/hooks/creation/useLogsViewer.js +2 -2
  79. package/dist/hooks/debug/useDebugLogs.js +4 -4
  80. package/dist/hooks/useContainerCommandRouter.js +14 -14
  81. package/dist/hooks/useContainers.js +3 -3
  82. package/dist/hooks/useControls.js +85 -38
  83. package/dist/hooks/useEraseConfirmation.js +3 -3
  84. package/dist/index.js +9 -2
  85. package/jest.config.cjs +7 -0
  86. package/package.json +7 -3
  87. package/src/App.jsx +39 -16
  88. package/src/components/ContainerCreationPrompt.jsx +54 -10
  89. package/src/components/ContainerList.jsx +3 -3
  90. package/src/components/ContainerRow.jsx +19 -16
  91. package/src/components/ContainerSection.jsx +3 -3
  92. package/src/components/ControlsHUD.jsx +66 -0
  93. package/src/components/Footer.jsx +2 -2
  94. package/src/components/Header.jsx +7 -7
  95. package/src/components/LogViewer.jsx +5 -3
  96. package/src/components/MessageFeedback.jsx +2 -2
  97. package/src/components/PromptField.jsx +4 -4
  98. package/src/components/StatsBar.jsx +11 -7
  99. package/src/components/SuggestionPanel.jsx +61 -0
  100. package/src/components/UsageMenu.jsx +18 -13
  101. package/src/helpers/appInfo.js +7 -7
  102. package/src/helpers/constants.js +48 -9
  103. package/src/helpers/containerOptionsBuilder.js +16 -5
  104. package/src/helpers/dockerHubService.js +75 -0
  105. package/src/helpers/dockerService/dockerService.js +1 -1
  106. package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
  107. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  108. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  109. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  110. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  111. package/src/helpers/exitWithMessage.js +13 -7
  112. package/src/helpers/imageNameUtils.js +32 -0
  113. package/src/helpers/logger.js +15 -16
  114. package/src/helpers/safeCall.js +2 -2
  115. package/src/helpers/validationHelpers.js +13 -29
  116. package/src/hooks/creation/useContainerActions.js +32 -12
  117. package/src/hooks/creation/useContainerCreation.js +293 -47
  118. package/src/hooks/creation/useLogsViewer.js +2 -2
  119. package/src/hooks/debug/useDebugLogs.js +15 -10
  120. package/src/hooks/navigation/useContainerSelection.js +17 -14
  121. package/src/hooks/useContainerCommandRouter.js +71 -58
  122. package/src/hooks/useContainers.js +4 -4
  123. package/src/hooks/useControls.js +135 -72
  124. package/src/hooks/useEraseConfirmation.js +3 -3
  125. package/src/hooks/useExitHandler.js +29 -26
  126. package/src/index.js +8 -4
  127. package/test/ContainerCreationPrompt.dom.test.js +313 -0
  128. package/test/ControlsHUD.dom.test.js +125 -0
  129. package/test/SuggestionPanel.dom.test.js +83 -0
  130. package/test/constants.test.js +41 -0
  131. package/test/dockerHubService.test.js +213 -0
  132. package/test/logger.test.js +58 -0
  133. package/test/resolveImageTag.test.js +26 -0
  134. package/test/useContainerCreation.dom.test.js +428 -2
  135. package/test/useContainerCreationHub.dom.test.js +184 -0
  136. package/test/useControls.dom.test.js +406 -4
@@ -6,7 +6,7 @@ const LOG_LEVELS = {
6
6
  error: 0,
7
7
  warn: 1,
8
8
  info: 2,
9
- debug: 3
9
+ debug: 3,
10
10
  };
11
11
 
12
12
  /**
@@ -24,7 +24,7 @@ const LOG_LEVELS = {
24
24
  */
25
25
 
26
26
  function parseLevel(value) {
27
- if (typeof value !== "string") {
27
+ if (typeof value !== 'string') {
28
28
  return null;
29
29
  }
30
30
  const normalized = value.trim().toLowerCase();
@@ -39,13 +39,13 @@ const envLevel =
39
39
  parseLevel(process.env.LOG_LEVEL) ??
40
40
  LOG_LEVELS.info;
41
41
 
42
- const currentLevel = typeof envLevel === "number" ? envLevel : LOG_LEVELS.info;
42
+ const currentLevel = typeof envLevel === 'number' ? envLevel : LOG_LEVELS.info;
43
43
 
44
44
  const listeners = new Set();
45
45
 
46
46
  function shouldLog(level) {
47
47
  const target = LOG_LEVELS[level];
48
- return typeof target === "number" && target <= currentLevel;
48
+ return typeof target === 'number' && target <= currentLevel;
49
49
  }
50
50
 
51
51
  function log(level, message, ...args) {
@@ -58,12 +58,11 @@ function log(level, message, ...args) {
58
58
  const formatted = `[${timestamp}] [${upperLevel}] ${message}`;
59
59
  const entry = { level, message, args, timestamp, formatted };
60
60
 
61
- const shouldWriteToConsole =
62
- listeners.size === 0 || level === "error" || level === "warn";
61
+ const shouldWriteToConsole = listeners.size === 0;
63
62
 
64
63
  if (shouldWriteToConsole) {
65
- const method = level === "debug" ? "log" : level;
66
- if (typeof console[method] === "function") {
64
+ const method = level === 'debug' ? 'log' : level;
65
+ if (typeof console[method] === 'function') {
67
66
  console[method](formatted, ...args);
68
67
  } else {
69
68
  console.log(formatted, ...args);
@@ -86,28 +85,28 @@ function log(level, message, ...args) {
86
85
  */
87
86
  export const logger = {
88
87
  debug(message, ...args) {
89
- log("debug", message, ...args);
88
+ log('debug', message, ...args);
90
89
  },
91
90
  info(message, ...args) {
92
- log("info", message, ...args);
91
+ log('info', message, ...args);
93
92
  },
94
93
  warn(message, ...args) {
95
- log("warn", message, ...args);
94
+ log('warn', message, ...args);
96
95
  },
97
96
  error(message, ...args) {
98
- log("error", message, ...args);
97
+ log('error', message, ...args);
99
98
  },
100
99
  /**
101
100
  * Subscribe to log events.
102
101
  *
103
- * @param {LogListener} listener Listener invoked for every log entry that passes the current level filter.
104
- * @returns {function(): void} Cleanup function that removes the listener when called.
102
+ * @param {LogListener} listener Listener invoked for every log entry that passes the current level filter.
103
+ * @returns {function(): void} Cleanup function that removes the listener when called.
105
104
  */
106
105
  subscribe(listener) {
107
- if (typeof listener !== "function") {
106
+ if (typeof listener !== 'function') {
108
107
  return () => {};
109
108
  }
110
109
  listeners.add(listener);
111
110
  return () => listeners.delete(listener);
112
- }
111
+ },
113
112
  };
@@ -1,4 +1,4 @@
1
- import { logger } from "./logger.js";
1
+ import { logger } from './logger.js';
2
2
 
3
3
  /**
4
4
  * Call a function safely: if it's a function call it with provided args and
@@ -12,7 +12,7 @@ export function safeCall(fn, ...args) {
12
12
  try {
13
13
  return fn(...args);
14
14
  } catch (e) {
15
- logger.error("safeCall caught error", e);
15
+ logger.error('safeCall caught error', e);
16
16
  }
17
17
  }
18
18
  }
@@ -1,4 +1,5 @@
1
1
  // Reusable validations for ports and environment variables
2
+ import { normalizeImageName } from './imageNameUtils.js';
2
3
 
3
4
  /**
4
5
  * Validate a comma-separated list of port mappings in the form "host:container".
@@ -10,38 +11,18 @@
10
11
  export function validatePorts(portInput) {
11
12
  // Empty or whitespace-only input is considered valid (ports are optional)
12
13
  if (!portInput || !portInput.trim()) return true;
13
- const ports = portInput.split(",").map(p => p.trim()).filter(Boolean);
14
+ const ports = portInput
15
+ .split(',')
16
+ .map((p) => p.trim())
17
+ .filter(Boolean);
14
18
  if (ports.length === 0) return true;
15
- const invalid = ports.find(pair => {
16
- const [host, cont] = pair.split(":");
19
+ const invalid = ports.find((pair) => {
20
+ const [host, cont] = pair.split(':');
17
21
  return !host || !cont || isNaN(Number(host)) || isNaN(Number(cont));
18
22
  });
19
23
  return !invalid;
20
24
  }
21
25
 
22
- /**
23
- * Normalize an image name to its base name (strips registry prefix and tag).
24
- * e.g. "docker.io/library/postgres:16-alpine" → "postgres"
25
- *
26
- * @param {string} imageName - Raw image name from user
27
- * @returns {string} Base image name (lowercase)
28
- */
29
- function normalizeImageName(imageName) {
30
- if (!imageName) return "";
31
- // Remove registry prefix (anything before the last / when it contains a dot or colon before the /)
32
- let name = imageName;
33
- const slashIdx = name.lastIndexOf("/");
34
- if (slashIdx !== -1) {
35
- name = name.slice(slashIdx + 1);
36
- }
37
- // Remove tag
38
- const colonIdx = name.indexOf(":");
39
- if (colonIdx !== -1) {
40
- name = name.slice(0, colonIdx);
41
- }
42
- return name.toLowerCase();
43
- }
44
-
45
26
  /**
46
27
  * Validate environment variable input as comma-separated VAR=value pairs.
47
28
  *
@@ -64,9 +45,12 @@ export function validateEnvVars(envInput, imageName, imageProfiles) {
64
45
  const syntaxErrors = [];
65
46
 
66
47
  if (envInput && envInput.trim()) {
67
- const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
48
+ const vars = envInput
49
+ .split(',')
50
+ .map((v) => v.trim())
51
+ .filter(Boolean);
68
52
  for (const v of vars) {
69
- const eqIdx = v.indexOf("=");
53
+ const eqIdx = v.indexOf('=');
70
54
  if (eqIdx === -1) {
71
55
  syntaxErrors.push(`"${v}" is missing an '=' sign`);
72
56
  continue;
@@ -93,7 +77,7 @@ export function validateEnvVars(envInput, imageName, imageProfiles) {
93
77
 
94
78
  if (profile && profile.requiredEnv && profile.requiredEnv.length) {
95
79
  for (const required of profile.requiredEnv) {
96
- if (!parsedEnv[required] || parsedEnv[required].trim() === "") {
80
+ if (!parsedEnv[required] || parsedEnv[required].trim() === '') {
97
81
  errors.push(`Missing required env var: ${required}`);
98
82
  }
99
83
  }
@@ -1,6 +1,11 @@
1
- import { useState } from "react";
2
- import { safeCall } from "../../helpers/safeCall.js";
3
- import { startContainer as svcStartContainer, stopContainer as svcStopContainer, restartContainer as svcRestartContainer, removeContainer as svcRemoveContainer } from "../../helpers/dockerService/serviceComponents/containerActions.js";
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { safeCall } from '../../helpers/safeCall.js';
3
+ import {
4
+ startContainer as svcStartContainer,
5
+ stopContainer as svcStopContainer,
6
+ restartContainer as svcRestartContainer,
7
+ removeContainer as svcRemoveContainer,
8
+ } from '../../helpers/dockerService/serviceComponents/containerActions.js';
4
9
 
5
10
  /**
6
11
  * Custom hook to manage container actions (start, stop, restart, remove).
@@ -12,8 +17,21 @@ import { startContainer as svcStartContainer, stopContainer as svcStopContainer,
12
17
  * @returns {Object} Action helpers and feedback state
13
18
  */
14
19
  export function useContainerActions({ containers, onAction }) {
15
- const [message, setMessage] = useState("");
16
- const [messageColor, setMessageColor] = useState("yellow");
20
+ const [message, setMessage] = useState('');
21
+ const [messageColor, setMessageColor] = useState('yellow');
22
+
23
+ const messageTimerRef = useRef(null);
24
+
25
+ function setTimedMessage(msg, color = 'yellow', ms = 4000) {
26
+ clearTimeout(messageTimerRef.current);
27
+ setMessage(msg);
28
+ if (color !== undefined) setMessageColor(color);
29
+ if (msg) {
30
+ messageTimerRef.current = setTimeout(() => setMessage(''), ms);
31
+ }
32
+ }
33
+
34
+ useEffect(() => () => clearTimeout(messageTimerRef.current), []);
17
35
 
18
36
  /**
19
37
  * Handles a generic container action and sets feedback.
@@ -29,26 +47,28 @@ export function useContainerActions({ containers, onAction }) {
29
47
  const checkMsg = stateCheck(container);
30
48
  if (checkMsg) {
31
49
  setMessage(checkMsg);
32
- setMessageColor("yellow");
50
+ setMessageColor('yellow');
33
51
  return;
34
52
  }
35
53
  }
36
54
  setMessage(`${actionLabel} container...`);
37
- setMessageColor("yellow");
55
+ setMessageColor('yellow');
38
56
  try {
39
57
  await actionFn(container.id);
40
- setMessage(`${actionLabel} container successful.`);
41
- setMessageColor("green");
42
- safeCall(onAction);
58
+ setTimedMessage(`${actionLabel} container successful.`, 'green');
59
+ safeCall(onAction);
43
60
  } catch (err) {
44
- setMessage(`Failed to ${actionLabel.toLowerCase()} container: ${err.message}`);
45
- setMessageColor("red");
61
+ setTimedMessage(
62
+ `Failed to ${actionLabel.toLowerCase()} container: ${err.message}`,
63
+ 'red'
64
+ );
46
65
  }
47
66
  }
48
67
 
49
68
  return {
50
69
  message,
51
70
  setMessage,
71
+ setTimedMessage,
52
72
  messageColor,
53
73
  setMessageColor,
54
74
  handleAction,
@@ -1,7 +1,35 @@
1
- import { useState } from "react";
2
- import { validatePorts, validateEnvVars } from "../../helpers/validationHelpers.js";
3
- import { IMAGE_PROFILES } from "../../helpers/constants.js";
4
- import { safeCall } from "../../helpers/safeCall.js";
1
+ import { useReducer, useState, useRef, useEffect } from 'react';
2
+ import {
3
+ validatePorts,
4
+ validateEnvVars,
5
+ } from '../../helpers/validationHelpers.js';
6
+ import { IMAGE_PROFILES, resolveImageTag } from '../../helpers/constants.js';
7
+ import { safeCall } from '../../helpers/safeCall.js';
8
+ import { searchDockerHub, formatHubResult } from '../../helpers/dockerHubService.js';
9
+
10
+ const MAX_VISIBLE = 6;
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
+ }
5
33
 
6
34
  /**
7
35
  * Custom hook to manage the container creation flow, step by step.
@@ -14,14 +42,183 @@ import { safeCall } from "../../helpers/safeCall.js";
14
42
  * @param {Object} [params.imageProfiles] - Image profiles map for contextual validation
15
43
  * @returns {Object} Creation state, setters, and helpers
16
44
  */
17
- export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageProfiles = IMAGE_PROFILES }) {
18
- const [step, setStep] = useState(0); // 0: image, 1: name, 2: ports, 3: env
19
- const [imageName, setImageName] = useState("");
20
- const [containerName, setContainerName] = useState("");
21
- const [portInput, setPortInput] = useState("");
22
- const [envInput, setEnvInput] = useState("");
23
- const [message, setMessage] = useState("");
24
- const [messageColor, setMessageColor] = useState("yellow");
45
+ export function useContainerCreation({
46
+ onCreate,
47
+ onCancel,
48
+ dbImages = [],
49
+ imageProfiles = IMAGE_PROFILES,
50
+ }) {
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), []);
120
+
121
+ /**
122
+ * Updates the image name input and recalculates autocomplete suggestions.
123
+ * Resets selectedSuggestionIndex to -1 on every keystroke.
124
+ * Also aborts any in-flight Hub search and clears Hub results.
125
+ *
126
+ * @param {string} value - New raw image name typed by user
127
+ */
128
+ function updateImageInput(value) {
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
+
135
+ const lower = value.toLowerCase();
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
+ }
188
+ }
189
+
190
+ /**
191
+ * Moves the suggestion selection up (-1) or down (+1).
192
+ * Clamps index to [-1, suggestions.length - 1].
193
+ * Adjusts visibleOffset to keep selected item in the visible window.
194
+ *
195
+ * @param {number} direction - -1 (up) or 1 (down)
196
+ */
197
+ function moveSuggestionSelection(direction) {
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 } });
203
+ }
204
+
205
+ /**
206
+ * Applies the currently focused suggestion to imageName.
207
+ * Does NOT advance the step. Clears suggestions after applying.
208
+ */
209
+ function applyFocusedSuggestion() {
210
+ if (selectedSuggestionIndex < 0 || selectedSuggestionIndex >= suggestions.length) return;
211
+ const chosen = suggestions[selectedSuggestionIndex];
212
+ dispatch({
213
+ type: 'SET',
214
+ payload: {
215
+ imageName: resolveImageTag(chosen, imageProfiles),
216
+ suggestions: [],
217
+ selectedSuggestionIndex: -1,
218
+ visibleOffset: 0,
219
+ },
220
+ });
221
+ }
25
222
 
26
223
  /**
27
224
  * Advances to the next step, with validation and feedback.
@@ -29,42 +226,70 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
29
226
  function nextStep() {
30
227
  if (step === 0) {
31
228
  if (!imageName.trim()) {
32
- setMessage("Image name cannot be empty.");
33
- setMessageColor("red");
229
+ setTimedMessage('Image name cannot be empty.', 'red');
34
230
  return;
35
231
  }
36
- setStep(1);
37
- setMessage("Optional: Enter container name or leave empty and press Enter");
38
- setMessageColor("yellow");
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'
237
+ );
39
238
  return;
40
239
  }
41
240
  if (step === 1) {
42
- setStep(2);
43
- setMessage("Optional: Enter ports (format 8080:80,443:443) or leave empty and press Enter");
44
- setMessageColor("yellow");
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'
245
+ );
45
246
  return;
46
247
  }
47
248
  if (step === 2) {
48
249
  // Ports are now optional - only validate if provided
49
250
  if (portInput.trim() && !validatePorts(portInput)) {
50
- setMessage("Port format must be host:container and both must be numbers (e.g. 8080:80)");
51
- setMessageColor("red");
251
+ setTimedMessage(
252
+ 'Port format must be host:container and both must be numbers (e.g. 8080:80)',
253
+ 'red'
254
+ );
52
255
  return;
53
256
  }
54
- setStep(3);
55
- const isDb = dbImages.some(db => imageName.trim().toLowerCase().includes(db));
257
+ dispatch({ type: 'SET', payload: { step: 3 } });
258
+ const isDb = dbImages.some((db) =>
259
+ imageName.trim().toLowerCase().includes(db)
260
+ );
56
261
  // Check if the image has a profile with required env vars
57
- const baseName = imageName.trim().toLowerCase().split(":")[0].split("/").pop();
262
+ const baseName = imageName
263
+ .trim()
264
+ .toLowerCase()
265
+ .split(':')[0]
266
+ .split('/')
267
+ .pop();
58
268
  const profile = imageProfiles[baseName];
59
269
  if (profile && profile.requiredEnv && profile.requiredEnv.length) {
60
- setMessage(`Required env vars for ${baseName}: ${profile.requiredEnv.join(", ")}. Enter as VAR=val,VAR2=val2`);
61
- setMessageColor("yellow");
270
+ const suggestedPart =
271
+ profile.suggestedEnv && profile.suggestedEnv.length
272
+ ? ` | Suggested: ${profile.suggestedEnv.join(', ')}`
273
+ : '';
274
+ setStepMessage(
275
+ `Required env vars for ${baseName}: ${profile.requiredEnv.join(', ')}. Enter as VAR=val,VAR2=val2${suggestedPart}`,
276
+ 'yellow'
277
+ );
278
+ } else if (profile && profile.suggestedEnv && profile.suggestedEnv.length) {
279
+ setStepMessage(
280
+ `Suggested env vars for ${baseName}: ${profile.suggestedEnv.join(', ')}. Enter as VAR=val,VAR2=val2 or leave empty.`,
281
+ 'yellow'
282
+ );
62
283
  } else if (isDb) {
63
- setMessage("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.");
64
- setMessageColor("yellow");
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'
287
+ );
65
288
  } else {
66
- setMessage("Optional: Enter environment variables (format VAR1=val1,VAR2=val2) or leave empty and press Enter");
67
- setMessageColor("yellow");
289
+ setStepMessage(
290
+ 'Optional: Enter environment variables (format VAR1=val1,VAR2=val2) or leave empty and press Enter',
291
+ 'yellow'
292
+ );
68
293
  }
69
294
  return;
70
295
  }
@@ -72,8 +297,7 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
72
297
  // Contextual env validation using image profiles
73
298
  const result = validateEnvVars(envInput, imageName, imageProfiles);
74
299
  if (!result.valid) {
75
- setMessage(result.errors.join(" | "));
76
- setMessageColor("red");
300
+ setTimedMessage(result.errors.join(' | '), 'red');
77
301
  return;
78
302
  }
79
303
  // Final step: call onCreate with all data (safely)
@@ -85,13 +309,7 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
85
309
  * Cancels the creation process and resets state.
86
310
  */
87
311
  function cancelCreation() {
88
- setStep(0);
89
- setImageName("");
90
- setContainerName("");
91
- setPortInput("");
92
- setEnvInput("");
93
- setMessage("");
94
- setMessageColor("yellow");
312
+ dispatch({ type: 'RESET' });
95
313
  safeCall(onCancel);
96
314
  }
97
315
 
@@ -100,15 +318,32 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
100
318
  * Used by the command router when the user presses 'c' to open the creation wizard.
101
319
  */
102
320
  function resetCreation() {
103
- setStep(0);
104
- setImageName("");
105
- setContainerName("");
106
- setPortInput("");
107
- setEnvInput("");
108
- setMessage("Insert the name of the image to create: ");
109
- setMessageColor("yellow");
321
+ dispatch({ type: 'RESET_WIZARD' });
110
322
  }
111
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
+
112
347
  return {
113
348
  step,
114
349
  setStep,
@@ -124,8 +359,19 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
124
359
  setMessage,
125
360
  messageColor,
126
361
  setMessageColor,
362
+ suggestions,
363
+ selectedSuggestionIndex,
364
+ visibleOffset,
365
+ isSearchingHub,
366
+ hubResults,
367
+ updateImageInput,
368
+ triggerHubSearch,
369
+ moveSuggestionSelection,
370
+ applyFocusedSuggestion,
127
371
  nextStep,
128
372
  cancelCreation,
129
373
  resetCreation,
374
+ insertNextSuggestedEnv,
375
+ hasSuggestedEnv,
130
376
  };
131
377
  }
@@ -1,5 +1,5 @@
1
- import { useState, useRef } from "react";
2
- import { safeCall } from "../../helpers/safeCall.js";
1
+ import { useState, useRef } from 'react';
2
+ import { safeCall } from '../../helpers/safeCall.js';
3
3
 
4
4
  /**
5
5
  * Custom hook to manage the logs viewer state and stream reference.