@visulima/vis 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (120) hide show
  1. package/CHANGELOG.md +403 -12
  2. package/LICENSE.md +283 -0
  3. package/README.md +254 -9
  4. package/dist/bin.js +9 -146
  5. package/dist/config/index.d.ts +1818 -0
  6. package/dist/config/index.js +2 -0
  7. package/dist/generate/index.d.ts +157 -0
  8. package/dist/generate/index.js +3 -0
  9. package/dist/packem_chunks/applyDefaults.js +336 -0
  10. package/dist/packem_chunks/bin.js +9577 -0
  11. package/dist/packem_chunks/doctor-probe.js +112 -0
  12. package/dist/packem_chunks/fix.js +234 -0
  13. package/dist/packem_chunks/handler.js +99 -0
  14. package/dist/packem_chunks/handler10.js +53 -0
  15. package/dist/packem_chunks/handler11.js +32 -0
  16. package/dist/packem_chunks/handler12.js +100 -0
  17. package/dist/packem_chunks/handler13.js +25 -0
  18. package/dist/packem_chunks/handler14.js +916 -0
  19. package/dist/packem_chunks/handler15.js +206 -0
  20. package/dist/packem_chunks/handler16.js +124 -0
  21. package/dist/packem_chunks/handler17.js +13 -0
  22. package/dist/packem_chunks/handler18.js +106 -0
  23. package/dist/packem_chunks/handler19.js +19 -0
  24. package/dist/packem_chunks/handler2.js +75 -0
  25. package/dist/packem_chunks/handler20.js +29 -0
  26. package/dist/packem_chunks/handler21.js +222 -0
  27. package/dist/packem_chunks/handler22.js +237 -0
  28. package/dist/packem_chunks/handler23.js +101 -0
  29. package/dist/packem_chunks/handler24.js +110 -0
  30. package/dist/packem_chunks/handler25.js +402 -0
  31. package/dist/packem_chunks/handler26.js +13 -0
  32. package/dist/packem_chunks/handler27.js +63 -0
  33. package/dist/packem_chunks/handler28.js +34 -0
  34. package/dist/packem_chunks/handler29.js +458 -0
  35. package/dist/packem_chunks/handler3.js +95 -0
  36. package/dist/packem_chunks/handler30.js +170 -0
  37. package/dist/packem_chunks/handler31.js +530 -0
  38. package/dist/packem_chunks/handler32.js +214 -0
  39. package/dist/packem_chunks/handler33.js +119 -0
  40. package/dist/packem_chunks/handler34.js +630 -0
  41. package/dist/packem_chunks/handler35.js +283 -0
  42. package/dist/packem_chunks/handler36.js +542 -0
  43. package/dist/packem_chunks/handler37.js +762 -0
  44. package/dist/packem_chunks/handler38.js +989 -0
  45. package/dist/packem_chunks/handler39.js +574 -0
  46. package/dist/packem_chunks/handler4.js +90 -0
  47. package/dist/packem_chunks/handler40.js +1685 -0
  48. package/dist/packem_chunks/handler41.js +1088 -0
  49. package/dist/packem_chunks/handler42.js +797 -0
  50. package/dist/packem_chunks/handler43.js +2658 -0
  51. package/dist/packem_chunks/handler44.js +3886 -0
  52. package/dist/packem_chunks/handler45.js +2574 -0
  53. package/dist/packem_chunks/handler46.js +3769 -0
  54. package/dist/packem_chunks/handler47.js +1491 -0
  55. package/dist/packem_chunks/handler5.js +174 -0
  56. package/dist/packem_chunks/handler6.js +95 -0
  57. package/dist/packem_chunks/handler7.js +115 -0
  58. package/dist/packem_chunks/handler8.js +12 -0
  59. package/dist/packem_chunks/handler9.js +29 -0
  60. package/dist/packem_chunks/heal-accept.js +522 -0
  61. package/dist/packem_chunks/heal.js +673 -0
  62. package/dist/packem_chunks/index.js +873 -0
  63. package/dist/packem_chunks/loader.js +23 -0
  64. package/dist/packem_shared/VisUpdateApp-D-Yz_wvg.js +1316 -0
  65. package/dist/packem_shared/_commonjsHelpers-BqLXS_qQ.js +5 -0
  66. package/dist/packem_shared/ai-analysis-CHeB1joD.js +367 -0
  67. package/dist/packem_shared/ai-cache-Be_jexe4.js +142 -0
  68. package/dist/packem_shared/ai-fix-B9iQVcD2.js +379 -0
  69. package/dist/packem_shared/cache-directory-2qvs4goY.js +98 -0
  70. package/dist/packem_shared/catalog-BJTtyi-O.js +1371 -0
  71. package/dist/packem_shared/dependency-scan-A0KSklpG.js +188 -0
  72. package/dist/packem_shared/docker-2iZzc280.js +181 -0
  73. package/dist/packem_shared/failure-log-Cz3Z4SKL.js +100 -0
  74. package/dist/packem_shared/flakiness-goTxXuCX.js +180 -0
  75. package/dist/packem_shared/otel-DCvqCTz_.js +158 -0
  76. package/dist/packem_shared/otelPlugin-DFaLDvJf.js +3 -0
  77. package/dist/packem_shared/registry-CbqXI0rc.js +272 -0
  78. package/dist/packem_shared/run-summary-utils-PVMl4aIh.js +130 -0
  79. package/dist/packem_shared/runtime-check-Cobi3p6l.js +127 -0
  80. package/dist/packem_shared/selectors-SM69TfqC.js +194 -0
  81. package/dist/packem_shared/symbols-Ta7g2nU-.js +14 -0
  82. package/dist/packem_shared/toolchain-BdZd9eBi.js +975 -0
  83. package/dist/packem_shared/typosquats-C-bCh3PX.js +1210 -0
  84. package/dist/packem_shared/use-measured-height-CNP0vT4M.js +20 -0
  85. package/dist/packem_shared/utils-CthVdBPS.js +40 -0
  86. package/dist/packem_shared/xxh3-Ck8mXNg1.js +239 -0
  87. package/index.js +773 -0
  88. package/package.json +82 -21
  89. package/schemas/project.schema.json +420 -0
  90. package/schemas/vis-config.schema.json +501 -0
  91. package/skills/vis/SKILL.md +96 -0
  92. package/templates/buildkite-ci/.buildkite/pipeline.yml.tera +85 -0
  93. package/templates/buildkite-ci/template.yml +20 -0
  94. package/dist/ai-analysis.d.ts +0 -40
  95. package/dist/ai-cache.d.ts +0 -21
  96. package/dist/bin.d.ts +0 -1
  97. package/dist/catalog.d.ts +0 -110
  98. package/dist/commands/affected.d.ts +0 -3
  99. package/dist/commands/ai.d.ts +0 -3
  100. package/dist/commands/analyze.d.ts +0 -3
  101. package/dist/commands/check.d.ts +0 -3
  102. package/dist/commands/graph.d.ts +0 -3
  103. package/dist/commands/hook/constants.d.ts +0 -8
  104. package/dist/commands/hook/index.d.ts +0 -3
  105. package/dist/commands/hook/install.d.ts +0 -7
  106. package/dist/commands/hook/migrate.d.ts +0 -27
  107. package/dist/commands/hook/uninstall.d.ts +0 -3
  108. package/dist/commands/migrate/constants.d.ts +0 -12
  109. package/dist/commands/migrate/deps.d.ts +0 -32
  110. package/dist/commands/migrate/index.d.ts +0 -3
  111. package/dist/commands/migrate/json.d.ts +0 -20
  112. package/dist/commands/migrate/lint-staged.d.ts +0 -62
  113. package/dist/commands/migrate/types.d.ts +0 -20
  114. package/dist/commands/run.d.ts +0 -3
  115. package/dist/commands/staged.d.ts +0 -3
  116. package/dist/commands/update.d.ts +0 -3
  117. package/dist/config.d.ts +0 -40
  118. package/dist/config.js +0 -1
  119. package/dist/package-manager.d.ts +0 -23
  120. package/dist/workspace.d.ts +0 -58
@@ -0,0 +1,2574 @@
1
+ import { createRequire as __cjs_createRequire } from "node:module";
2
+
3
+ const __cjs_require = __cjs_createRequire(import.meta.url);
4
+
5
+ const __cjs_getProcess = typeof globalThis !== "undefined" && typeof globalThis.process !== "undefined" ? globalThis.process : process;
6
+
7
+ const __cjs_getBuiltinModule = (module) => {
8
+ // Check if we're in Node.js and version supports getBuiltinModule
9
+ if (typeof __cjs_getProcess !== "undefined" && __cjs_getProcess.versions && __cjs_getProcess.versions.node) {
10
+ const [major, minor] = __cjs_getProcess.versions.node.split(".").map(Number);
11
+ // Node.js 20.16.0+ and 22.3.0+
12
+ if (major > 22 || (major === 22 && minor >= 3) || (major === 20 && minor >= 16)) {
13
+ return __cjs_getProcess.getBuiltinModule(module);
14
+ }
15
+ }
16
+ // Fallback to createRequire
17
+ return __cjs_require(module);
18
+ };
19
+
20
+ import { Box, Text, TextInput, ScrollBar, ScrollView, useApp, useWindowSize, useInput, Dialog, Tabs, Tab, render } from '@visulima/tui';
21
+ import { _ as QuitDialog, c as detectPm, i as isInCi } from './bin.js';
22
+ import React, { useMemo, useSyncExternalStore, useState, useRef, useEffect, useCallback } from 'react';
23
+ const {
24
+ writeFileSync
25
+ } = __cjs_getBuiltinModule("node:fs");
26
+ import { isAccessibleSync, readFileSync, ensureDirSync } from '@visulima/fs';
27
+ import { stripJsonComments } from '@visulima/fs/utils';
28
+ import { join, dirname } from '@visulima/path';
29
+ import { jsxs, jsx } from 'react/jsx-runtime';
30
+
31
+ const TEMPLATES = [
32
+ {
33
+ config: {
34
+ customizations: {
35
+ vscode: {
36
+ extensions: ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
37
+ }
38
+ },
39
+ features: {
40
+ "ghcr.io/devcontainers/features/git:1": {},
41
+ "ghcr.io/devcontainers/features/github-cli:1": {}
42
+ },
43
+ forwardPorts: [3e3],
44
+ image: "mcr.microsoft.com/devcontainers/javascript-node:22",
45
+ name: "Node.js",
46
+ postCreateCommand: "npm install"
47
+ },
48
+ description: "Node.js 22 with Git and GitHub CLI",
49
+ id: "node",
50
+ name: "Node.js"
51
+ },
52
+ {
53
+ config: {
54
+ customizations: {
55
+ vscode: {
56
+ extensions: ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
57
+ }
58
+ },
59
+ features: {
60
+ "ghcr.io/devcontainers/features/git:1": {},
61
+ "ghcr.io/devcontainers/features/github-cli:1": {}
62
+ },
63
+ forwardPorts: [3e3],
64
+ image: "mcr.microsoft.com/devcontainers/javascript-node:22",
65
+ mounts: [
66
+ {
67
+ source: "${localWorkspaceFolderBasename}-node_modules",
68
+ target: "${containerWorkspaceFolder}/node_modules",
69
+ type: "volume"
70
+ },
71
+ {
72
+ source: "${localWorkspaceFolderBasename}-pnpm-store",
73
+ target: "/home/node/.local/share/pnpm/store",
74
+ type: "volume"
75
+ }
76
+ ],
77
+ name: "Node.js + pnpm Monorepo",
78
+ postCreateCommand: "corepack enable && pnpm install",
79
+ remoteUser: "node",
80
+ workspaceFolder: "/workspaces/${localWorkspaceFolderBasename}"
81
+ },
82
+ description: "Node.js 22 with pnpm, corepack, and optimized volume mounts",
83
+ id: "node-pnpm",
84
+ name: "Node.js + pnpm"
85
+ },
86
+ {
87
+ config: {
88
+ customizations: {
89
+ vscode: {
90
+ extensions: ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-azuretools.vscode-docker"]
91
+ }
92
+ },
93
+ dockerComposeFile: "docker-compose.yml",
94
+ forwardPorts: [3e3, 5432],
95
+ name: "Node.js + PostgreSQL",
96
+ postCreateCommand: "npm install",
97
+ service: "app",
98
+ workspaceFolder: "/workspaces/${localWorkspaceFolderBasename}"
99
+ },
100
+ description: "Node.js with PostgreSQL via Docker Compose",
101
+ id: "node-postgres",
102
+ name: "Node.js + PostgreSQL"
103
+ },
104
+ {
105
+ config: {
106
+ customizations: {
107
+ vscode: {
108
+ extensions: ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-azuretools.vscode-docker"]
109
+ }
110
+ },
111
+ features: {
112
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {},
113
+ "ghcr.io/devcontainers/features/git:1": {},
114
+ "ghcr.io/devcontainers/features/github-cli:1": {}
115
+ },
116
+ forwardPorts: [3e3],
117
+ image: "mcr.microsoft.com/devcontainers/javascript-node:22",
118
+ name: "Node.js + Docker",
119
+ postCreateCommand: "npm install"
120
+ },
121
+ description: "Node.js 22 with Docker-in-Docker for container workflows",
122
+ id: "node-dind",
123
+ name: "Node.js + Docker-in-Docker"
124
+ },
125
+ {
126
+ config: {
127
+ customizations: {
128
+ vscode: {
129
+ extensions: ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-azuretools.vscode-docker"]
130
+ }
131
+ },
132
+ dockerComposeFile: "docker-compose.yml",
133
+ features: {
134
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {}
135
+ },
136
+ forwardPorts: [3e3, 5432, 6379],
137
+ name: "Full Stack",
138
+ postCreateCommand: "npm install",
139
+ service: "app",
140
+ workspaceFolder: "/workspaces/${localWorkspaceFolderBasename}"
141
+ },
142
+ description: "Node.js + PostgreSQL + Redis + Docker via Compose",
143
+ id: "fullstack",
144
+ name: "Full Stack"
145
+ },
146
+ {
147
+ config: {
148
+ customizations: {
149
+ vscode: {
150
+ extensions: ["ms-python.python", "ms-python.vscode-pylance"],
151
+ settings: {
152
+ "editor.formatOnSave": true,
153
+ "python.defaultInterpreterPath": "/usr/local/bin/python"
154
+ }
155
+ }
156
+ },
157
+ features: {
158
+ "ghcr.io/devcontainers/features/git:1": {},
159
+ "ghcr.io/devcontainers/features/github-cli:1": {},
160
+ "ghcr.io/devcontainers/features/python:1": { version: "3.12" }
161
+ },
162
+ forwardPorts: [8e3],
163
+ image: "mcr.microsoft.com/devcontainers/python:3.12",
164
+ name: "Python",
165
+ postCreateCommand: "pip install -r requirements.txt || true"
166
+ },
167
+ description: "Python 3.12 with pip and venv",
168
+ id: "python",
169
+ name: "Python"
170
+ },
171
+ {
172
+ config: {
173
+ customizations: {
174
+ vscode: {
175
+ extensions: ["golang.go"],
176
+ settings: {
177
+ "editor.formatOnSave": true,
178
+ "go.toolsManagement.autoUpdate": true
179
+ }
180
+ }
181
+ },
182
+ features: {
183
+ "ghcr.io/devcontainers/features/git:1": {},
184
+ "ghcr.io/devcontainers/features/go:1": { version: "1.22" }
185
+ },
186
+ forwardPorts: [8080],
187
+ image: "mcr.microsoft.com/devcontainers/go:1.22",
188
+ name: "Go",
189
+ postCreateCommand: "go mod download || true"
190
+ },
191
+ description: "Go 1.22 development environment",
192
+ id: "go",
193
+ name: "Go"
194
+ },
195
+ {
196
+ config: {
197
+ customizations: {
198
+ vscode: {
199
+ extensions: ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"],
200
+ settings: {
201
+ "editor.formatOnSave": true
202
+ }
203
+ }
204
+ },
205
+ features: {
206
+ "ghcr.io/devcontainers/features/git:1": {},
207
+ "ghcr.io/devcontainers/features/rust:1": {}
208
+ },
209
+ image: "mcr.microsoft.com/devcontainers/rust:latest",
210
+ name: "Rust",
211
+ postCreateCommand: "cargo build || true"
212
+ },
213
+ description: "Rust development with cargo and rust-analyzer",
214
+ id: "rust",
215
+ name: "Rust"
216
+ },
217
+ {
218
+ config: {
219
+ customizations: {
220
+ vscode: {
221
+ extensions: ["vscjava.vscode-java-pack", "vscjava.vscode-maven"]
222
+ }
223
+ },
224
+ features: {
225
+ "ghcr.io/devcontainers/features/git:1": {},
226
+ "ghcr.io/devcontainers/features/java:1": { version: "17" }
227
+ },
228
+ forwardPorts: [8080],
229
+ image: "mcr.microsoft.com/devcontainers/java:17",
230
+ name: "Java",
231
+ postCreateCommand: "./mvnw install || ./gradlew build || true"
232
+ },
233
+ description: "Java 17 with Maven/Gradle support",
234
+ id: "java",
235
+ name: "Java"
236
+ },
237
+ {
238
+ config: {
239
+ customizations: {
240
+ vscode: {
241
+ extensions: ["ms-azuretools.vscode-docker", "ms-kubernetes-tools.vscode-kubernetes-tools", "hashicorp.terraform"]
242
+ }
243
+ },
244
+ features: {
245
+ "ghcr.io/devcontainers/features/aws-cli:1": {},
246
+ "ghcr.io/devcontainers/features/azure-cli:1": {},
247
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {},
248
+ "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {},
249
+ "ghcr.io/devcontainers/features/terraform:1": {}
250
+ },
251
+ image: "mcr.microsoft.com/devcontainers/base:ubuntu",
252
+ name: "DevOps"
253
+ },
254
+ description: "Docker, Kubernetes, Terraform, AWS & Azure CLIs",
255
+ id: "devops",
256
+ name: "DevOps"
257
+ },
258
+ {
259
+ config: {
260
+ features: {
261
+ "ghcr.io/devcontainers/features/common-utils:2": {}
262
+ },
263
+ image: "mcr.microsoft.com/devcontainers/base:ubuntu",
264
+ name: "Minimal",
265
+ remoteUser: "vscode"
266
+ },
267
+ description: "Bare Ubuntu with common utilities",
268
+ id: "minimal",
269
+ name: "Minimal"
270
+ },
271
+ {
272
+ config: {
273
+ image: "mcr.microsoft.com/devcontainers/base:ubuntu",
274
+ name: "Custom"
275
+ },
276
+ description: "Minimal Ubuntu base - configure from scratch",
277
+ id: "custom",
278
+ name: "Custom (Blank)"
279
+ }
280
+ ];
281
+
282
+ const readDevcontainerJson = (workspaceRoot) => {
283
+ const filePath = join(workspaceRoot, ".devcontainer", "devcontainer.json");
284
+ if (!isAccessibleSync(filePath)) {
285
+ return null;
286
+ }
287
+ const raw = readFileSync(filePath);
288
+ const stripped = stripJsonComments(raw);
289
+ const hadComments = stripped !== raw;
290
+ let config;
291
+ try {
292
+ config = JSON.parse(stripped);
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ throw new Error(`Failed to parse ${filePath}: ${message}`);
296
+ }
297
+ return { config, hadComments };
298
+ };
299
+ const writeDevcontainerJson = (workspaceRoot, config, outputPath) => {
300
+ const dir = outputPath ? dirname(outputPath) : join(workspaceRoot, ".devcontainer");
301
+ const filePath = outputPath ?? join(dir, "devcontainer.json");
302
+ ensureDirSync(dir);
303
+ writeFileSync(filePath, `${JSON.stringify(config, null, 2)}
304
+ `, "utf8");
305
+ };
306
+
307
+ const PM_MOUNTS = {
308
+ bun: [
309
+ {
310
+ source: "${localWorkspaceFolderBasename}-node_modules",
311
+ target: "${containerWorkspaceFolder}/node_modules",
312
+ type: "volume"
313
+ },
314
+ {
315
+ source: "${localWorkspaceFolderBasename}-bun-cache",
316
+ target: "/home/node/.bun/install/cache",
317
+ type: "volume"
318
+ }
319
+ ],
320
+ npm: [
321
+ {
322
+ source: "${localWorkspaceFolderBasename}-node_modules",
323
+ target: "${containerWorkspaceFolder}/node_modules",
324
+ type: "volume"
325
+ },
326
+ {
327
+ source: "${localWorkspaceFolderBasename}-npm-cache",
328
+ target: "/home/node/.npm",
329
+ type: "volume"
330
+ }
331
+ ],
332
+ pnpm: [
333
+ {
334
+ source: "${localWorkspaceFolderBasename}-node_modules",
335
+ target: "${containerWorkspaceFolder}/node_modules",
336
+ type: "volume"
337
+ },
338
+ {
339
+ source: "${localWorkspaceFolderBasename}-pnpm-store",
340
+ target: "/home/node/.local/share/pnpm/store",
341
+ type: "volume"
342
+ }
343
+ ],
344
+ yarn: [
345
+ {
346
+ source: "${localWorkspaceFolderBasename}-node_modules",
347
+ target: "${containerWorkspaceFolder}/node_modules",
348
+ type: "volume"
349
+ },
350
+ {
351
+ source: "${localWorkspaceFolderBasename}-yarn-cache",
352
+ target: "/home/node/.yarn/cache",
353
+ type: "volume"
354
+ }
355
+ ]
356
+ };
357
+ const FEATURE_MOUNTS = [
358
+ {
359
+ featureMatch: "docker-in-docker",
360
+ mounts: []
361
+ // Docker-in-Docker manages its own storage
362
+ },
363
+ {
364
+ featureMatch: "docker-outside-of-docker",
365
+ mounts: [
366
+ {
367
+ source: "/var/run/docker.sock",
368
+ target: "/var/run/docker.sock",
369
+ type: "bind"
370
+ }
371
+ ]
372
+ },
373
+ {
374
+ featureMatch: "/features/git:",
375
+ mounts: [
376
+ {
377
+ source: "${localWorkspaceFolderBasename}-git-config",
378
+ target: "/home/node/.gitconfig",
379
+ type: "volume"
380
+ }
381
+ ]
382
+ }
383
+ ];
384
+ const getSuggestedMounts = (pm, enabledFeatures, currentMounts) => {
385
+ const suggestions = [];
386
+ const existingTargets = new Set(currentMounts.map((m) => typeof m === "string" ? m : m.target));
387
+ if (pm) {
388
+ for (const mount of PM_MOUNTS[pm]) {
389
+ if (!existingTargets.has(mount.target)) {
390
+ suggestions.push(mount);
391
+ }
392
+ }
393
+ }
394
+ const featureIds = Object.keys(enabledFeatures);
395
+ for (const { featureMatch, mounts } of FEATURE_MOUNTS) {
396
+ const hasFeature = featureIds.some((id) => id.includes(featureMatch));
397
+ if (hasFeature) {
398
+ for (const mount of mounts) {
399
+ if (!existingTargets.has(mount.target)) {
400
+ suggestions.push(mount);
401
+ }
402
+ }
403
+ }
404
+ }
405
+ return suggestions;
406
+ };
407
+
408
+ const SECTION_ORDER = ["general", "features", "ports", "lifecycle", "extensions", "environment", "mounts", "compose"];
409
+
410
+ const deepClone = (value) => structuredClone(value);
411
+ class DevcontainerStore {
412
+ #listeners = /* @__PURE__ */ new Set();
413
+ #state;
414
+ constructor(config, hadComments, detectedPm = null) {
415
+ const isCreate = config === null;
416
+ const initial = config ?? { name: "" };
417
+ const cloned = deepClone(initial);
418
+ this.#state = {
419
+ config: cloned,
420
+ detectedPm,
421
+ extensionSearch: "",
422
+ featureSearch: "",
423
+ fieldEditing: false,
424
+ fieldIndex: 0,
425
+ hadComments,
426
+ isDirty: false,
427
+ mode: isCreate ? "create" : "edit",
428
+ originalConfig: isCreate ? null : deepClone(initial),
429
+ section: "general",
430
+ showTemplateSelector: isCreate,
431
+ suggestedMounts: getSuggestedMounts(detectedPm, cloned.features ?? {}, cloned.mounts ?? []),
432
+ templateIndex: 0
433
+ };
434
+ }
435
+ // ── React integration ───────────────────────────────────────────
436
+ getSnapshot = () => this.#state;
437
+ subscribe = (listener) => {
438
+ this.#listeners.add(listener);
439
+ return () => {
440
+ this.#listeners.delete(listener);
441
+ };
442
+ };
443
+ // ── Tab navigation ──────────────────────────────────────────────
444
+ setSection(section) {
445
+ if (section !== this.#state.section) {
446
+ this.#emit({
447
+ ...this.#state,
448
+ fieldEditing: false,
449
+ fieldIndex: 0,
450
+ section
451
+ });
452
+ }
453
+ }
454
+ nextSection() {
455
+ const current = SECTION_ORDER.indexOf(this.#state.section);
456
+ const next = (current + 1) % SECTION_ORDER.length;
457
+ this.setSection(SECTION_ORDER[next]);
458
+ }
459
+ previousSection() {
460
+ const current = SECTION_ORDER.indexOf(this.#state.section);
461
+ const previous = (current - 1 + SECTION_ORDER.length) % SECTION_ORDER.length;
462
+ this.setSection(SECTION_ORDER[previous]);
463
+ }
464
+ // ── Field navigation ────────────────────────────────────────────
465
+ setFieldIndex(index) {
466
+ if (index !== this.#state.fieldIndex) {
467
+ this.#emit({ ...this.#state, fieldIndex: Math.max(0, index) });
468
+ }
469
+ }
470
+ setFieldEditing(editing) {
471
+ if (editing !== this.#state.fieldEditing) {
472
+ this.#emit({ ...this.#state, fieldEditing: editing });
473
+ }
474
+ }
475
+ // ── Template selector ───────────────────────────────────────────
476
+ setTemplateIndex(index) {
477
+ const clamped = Math.max(0, Math.min(index, TEMPLATES.length - 1));
478
+ if (clamped !== this.#state.templateIndex) {
479
+ this.#emit({ ...this.#state, templateIndex: clamped });
480
+ }
481
+ }
482
+ applyTemplate(templateId) {
483
+ const template = TEMPLATES.find((t) => t.id === templateId);
484
+ if (template) {
485
+ this.#emit(
486
+ this.#withSuggestions({
487
+ ...this.#state,
488
+ config: deepClone(template.config),
489
+ isDirty: true,
490
+ showTemplateSelector: false
491
+ })
492
+ );
493
+ }
494
+ }
495
+ dismissTemplateSelector() {
496
+ this.#emit({ ...this.#state, showTemplateSelector: false });
497
+ }
498
+ // ── General config updates ──────────────────────────────────────
499
+ updateConfig(partial) {
500
+ this.#emit({
501
+ ...this.#state,
502
+ config: { ...this.#state.config, ...partial },
503
+ isDirty: true
504
+ });
505
+ }
506
+ // ── Features ────────────────────────────────────────────────────
507
+ toggleFeature(featureId) {
508
+ const features = { ...this.#state.config.features };
509
+ if (features[featureId] === void 0) {
510
+ features[featureId] = {};
511
+ } else {
512
+ delete features[featureId];
513
+ }
514
+ this.#emit(
515
+ this.#withSuggestions({
516
+ ...this.#state,
517
+ config: { ...this.#state.config, features },
518
+ isDirty: true
519
+ })
520
+ );
521
+ }
522
+ setFeatureSearch(search) {
523
+ this.#emit({ ...this.#state, featureSearch: search, fieldIndex: 0 });
524
+ }
525
+ // ── Ports ───────────────────────────────────────────────────────
526
+ addPort(port) {
527
+ const existing = this.#state.config.forwardPorts ?? [];
528
+ if (existing.includes(port)) {
529
+ return;
530
+ }
531
+ const ports = [...existing, port];
532
+ this.#emit({
533
+ ...this.#state,
534
+ config: { ...this.#state.config, forwardPorts: ports },
535
+ isDirty: true
536
+ });
537
+ }
538
+ removePort(index) {
539
+ const ports = [...this.#state.config.forwardPorts ?? []];
540
+ ports.splice(index, 1);
541
+ this.#emit({
542
+ ...this.#state,
543
+ config: { ...this.#state.config, forwardPorts: ports.length > 0 ? ports : void 0 },
544
+ isDirty: true
545
+ });
546
+ }
547
+ // ── Extensions ──────────────────────────────────────────────────
548
+ toggleExtension(extensionId) {
549
+ const customizations = { ...this.#state.config.customizations };
550
+ const vscode = { ...customizations.vscode };
551
+ const extensions = [...vscode.extensions ?? []];
552
+ const index = extensions.indexOf(extensionId);
553
+ if (index === -1) {
554
+ extensions.push(extensionId);
555
+ } else {
556
+ extensions.splice(index, 1);
557
+ }
558
+ vscode.extensions = extensions.length > 0 ? extensions : void 0;
559
+ customizations.vscode = vscode.extensions || vscode.settings ? vscode : void 0;
560
+ this.#emit({
561
+ ...this.#state,
562
+ config: {
563
+ ...this.#state.config,
564
+ customizations: customizations.vscode || customizations.jetbrains ? customizations : void 0
565
+ },
566
+ isDirty: true
567
+ });
568
+ }
569
+ setExtensionSearch(search) {
570
+ this.#emit({ ...this.#state, extensionSearch: search, fieldIndex: 0 });
571
+ }
572
+ // ── Environment variables ───────────────────────────────────────
573
+ addEnvVar(target, key, value) {
574
+ const field = target === "container" ? "containerEnv" : "remoteEnv";
575
+ const env = { ...this.#state.config[field], [key]: value };
576
+ this.#emit({
577
+ ...this.#state,
578
+ config: { ...this.#state.config, [field]: env },
579
+ isDirty: true
580
+ });
581
+ }
582
+ removeEnvVar(target, key) {
583
+ const field = target === "container" ? "containerEnv" : "remoteEnv";
584
+ const env = { ...this.#state.config[field] };
585
+ delete env[key];
586
+ this.#emit({
587
+ ...this.#state,
588
+ config: { ...this.#state.config, [field]: Object.keys(env).length > 0 ? env : void 0 },
589
+ isDirty: true
590
+ });
591
+ }
592
+ // ── Mounts ──────────────────────────────────────────────────────
593
+ addMount(mount) {
594
+ const mounts = [...this.#state.config.mounts ?? [], mount];
595
+ this.#emit(
596
+ this.#withSuggestions({
597
+ ...this.#state,
598
+ config: { ...this.#state.config, mounts },
599
+ isDirty: true
600
+ })
601
+ );
602
+ }
603
+ removeMount(index) {
604
+ const mounts = [...this.#state.config.mounts ?? []];
605
+ mounts.splice(index, 1);
606
+ this.#emit(
607
+ this.#withSuggestions({
608
+ ...this.#state,
609
+ config: { ...this.#state.config, mounts: mounts.length > 0 ? mounts : void 0 },
610
+ isDirty: true
611
+ })
612
+ );
613
+ }
614
+ /** Add all currently suggested mounts to the config. */
615
+ applySuggestedMounts() {
616
+ if (this.#state.suggestedMounts.length === 0) {
617
+ return;
618
+ }
619
+ const mounts = [...this.#state.config.mounts ?? [], ...this.#state.suggestedMounts];
620
+ this.#emit(
621
+ this.#withSuggestions({
622
+ ...this.#state,
623
+ config: { ...this.#state.config, mounts },
624
+ isDirty: true
625
+ })
626
+ );
627
+ }
628
+ // ── Lifecycle commands ───────────────────────────────────────────
629
+ setLifecycleCommand(hook, command) {
630
+ this.#emit({
631
+ ...this.#state,
632
+ config: { ...this.#state.config, [hook]: command || void 0 },
633
+ isDirty: true
634
+ });
635
+ }
636
+ // ── Save lifecycle ────────────────────────────────────────────────
637
+ markClean() {
638
+ this.#emit({
639
+ ...this.#state,
640
+ isDirty: false,
641
+ originalConfig: deepClone(this.#state.config)
642
+ });
643
+ }
644
+ // ── Preview / Serialization ─────────────────────────────────────
645
+ getJsonPreview() {
646
+ return JSON.stringify(this.#cleanConfig(), null, 2);
647
+ }
648
+ /** Return a cleaned config with empty/undefined fields stripped. */
649
+ cleanConfig() {
650
+ return this.#cleanConfig();
651
+ }
652
+ // ── Internal ────────────────────────────────────────────────────
653
+ #cleanConfig() {
654
+ const config = deepClone(this.#state.config);
655
+ for (const [key, value] of Object.entries(config)) {
656
+ if (value === "" || value === void 0) {
657
+ delete config[key];
658
+ }
659
+ }
660
+ if (config.build) {
661
+ if (config.build.dockerfile === "") {
662
+ delete config.build.dockerfile;
663
+ }
664
+ if (config.build.context === "") {
665
+ delete config.build.context;
666
+ }
667
+ if (config.build.args && Object.keys(config.build.args).length === 0) {
668
+ delete config.build.args;
669
+ }
670
+ if (Object.keys(config.build).length === 0) {
671
+ delete config.build;
672
+ }
673
+ }
674
+ if (config.forwardPorts?.length === 0) {
675
+ delete config.forwardPorts;
676
+ }
677
+ if (config.mounts?.length === 0) {
678
+ delete config.mounts;
679
+ }
680
+ if (config.runServices?.length === 0) {
681
+ delete config.runServices;
682
+ }
683
+ if (config.capAdd?.length === 0) {
684
+ delete config.capAdd;
685
+ }
686
+ if (config.securityOpt?.length === 0) {
687
+ delete config.securityOpt;
688
+ }
689
+ if (config.features && Object.keys(config.features).length === 0) {
690
+ delete config.features;
691
+ }
692
+ if (config.customizations?.vscode?.extensions?.length === 0) {
693
+ delete config.customizations.vscode.extensions;
694
+ }
695
+ if (config.customizations?.vscode && Object.keys(config.customizations.vscode).length === 0) {
696
+ delete config.customizations.vscode;
697
+ }
698
+ if (config.customizations && Object.keys(config.customizations).length === 0) {
699
+ delete config.customizations;
700
+ }
701
+ if (config.containerEnv && Object.keys(config.containerEnv).length === 0) {
702
+ delete config.containerEnv;
703
+ }
704
+ if (config.remoteEnv && Object.keys(config.remoteEnv).length === 0) {
705
+ delete config.remoteEnv;
706
+ }
707
+ return config;
708
+ }
709
+ /** Recalculate suggested mounts for the given state. */
710
+ #withSuggestions(state) {
711
+ return {
712
+ ...state,
713
+ suggestedMounts: getSuggestedMounts(state.detectedPm, state.config.features ?? {}, state.config.mounts ?? [])
714
+ };
715
+ }
716
+ #emit(newState) {
717
+ this.#state = newState;
718
+ for (const listener of this.#listeners) {
719
+ try {
720
+ listener();
721
+ } catch {
722
+ }
723
+ }
724
+ }
725
+ }
726
+
727
+ const EXTENSION_CATALOG = [
728
+ // Linting
729
+ {
730
+ category: "linting",
731
+ description: "Integrates ESLint into the editor",
732
+ id: "dbaeumer.vscode-eslint",
733
+ name: "ESLint"
734
+ },
735
+ {
736
+ category: "linting",
737
+ description: "Stylelint CSS/SCSS linting",
738
+ id: "stylelint.vscode-stylelint",
739
+ name: "Stylelint"
740
+ },
741
+ // Formatting
742
+ {
743
+ category: "formatting",
744
+ description: "Opinionated code formatter",
745
+ id: "esbenp.prettier-vscode",
746
+ name: "Prettier"
747
+ },
748
+ {
749
+ category: "formatting",
750
+ description: "EditorConfig file support",
751
+ id: "editorconfig.editorconfig",
752
+ name: "EditorConfig"
753
+ },
754
+ {
755
+ category: "formatting",
756
+ description: "Fast Rust-based formatter and linter",
757
+ id: "biomejs.biome",
758
+ name: "Biome"
759
+ },
760
+ // Language
761
+ {
762
+ category: "language",
763
+ description: "Rich TypeScript and JavaScript support",
764
+ id: "ms-vscode.vscode-typescript-next",
765
+ name: "TypeScript Nightly"
766
+ },
767
+ {
768
+ category: "language",
769
+ description: "Tailwind CSS IntelliSense",
770
+ id: "bradlc.vscode-tailwindcss",
771
+ name: "Tailwind CSS"
772
+ },
773
+ {
774
+ category: "language",
775
+ description: "YAML language support with schemas",
776
+ id: "redhat.vscode-yaml",
777
+ name: "YAML"
778
+ },
779
+ {
780
+ category: "language",
781
+ description: "TOML language support",
782
+ id: "tamasfe.even-better-toml",
783
+ name: "TOML"
784
+ },
785
+ {
786
+ category: "language",
787
+ description: "Dockerfile and Docker Compose support",
788
+ id: "ms-azuretools.vscode-docker",
789
+ name: "Docker"
790
+ },
791
+ {
792
+ category: "language",
793
+ description: "Python language support with Pylance",
794
+ id: "ms-python.python",
795
+ name: "Python"
796
+ },
797
+ {
798
+ category: "language",
799
+ description: "Go language support",
800
+ id: "golang.go",
801
+ name: "Go"
802
+ },
803
+ {
804
+ category: "language",
805
+ description: "Rust language support via rust-analyzer",
806
+ id: "rust-lang.rust-analyzer",
807
+ name: "rust-analyzer"
808
+ },
809
+ // Git
810
+ {
811
+ category: "git",
812
+ description: "Git supercharged: blame, history, stash, etc.",
813
+ id: "eamodio.gitlens",
814
+ name: "GitLens"
815
+ },
816
+ {
817
+ category: "git",
818
+ description: "GitHub Pull Requests and Issues",
819
+ id: "github.vscode-pull-request-github",
820
+ name: "GitHub PR"
821
+ },
822
+ // Testing
823
+ {
824
+ category: "testing",
825
+ description: "Vitest test explorer integration",
826
+ id: "vitest.explorer",
827
+ name: "Vitest Explorer"
828
+ },
829
+ {
830
+ category: "testing",
831
+ description: "Jest test runner and assertions",
832
+ id: "orta.vscode-jest",
833
+ name: "Jest"
834
+ },
835
+ // Debugging
836
+ {
837
+ category: "debugging",
838
+ description: "REST client for testing APIs",
839
+ id: "humao.rest-client",
840
+ name: "REST Client"
841
+ },
842
+ {
843
+ category: "debugging",
844
+ description: "Error Lens: inline error highlighting",
845
+ id: "usernamehw.errorlens",
846
+ name: "Error Lens"
847
+ },
848
+ // Other
849
+ {
850
+ category: "other",
851
+ description: "Intelligent code completion with AI",
852
+ id: "github.copilot",
853
+ name: "GitHub Copilot"
854
+ },
855
+ {
856
+ category: "other",
857
+ description: "Path autocompletion for imports",
858
+ id: "christian-kohler.path-intellisense",
859
+ name: "Path Intellisense"
860
+ },
861
+ {
862
+ category: "other",
863
+ description: "Import cost display in editor",
864
+ id: "wix.vscode-import-cost",
865
+ name: "Import Cost"
866
+ },
867
+ {
868
+ category: "other",
869
+ description: "Todo Tree: highlight and list TODOs",
870
+ id: "gruntfuggly.todo-tree",
871
+ name: "Todo Tree"
872
+ }
873
+ ];
874
+
875
+ const FEATURE_CATALOG = [
876
+ // Languages
877
+ {
878
+ category: "language",
879
+ description: "Node.js runtime via nvm with optional pnpm/yarn",
880
+ id: "ghcr.io/devcontainers/features/node:1",
881
+ name: "Node.js"
882
+ },
883
+ {
884
+ category: "language",
885
+ description: "Python runtime with pip and optional tools",
886
+ id: "ghcr.io/devcontainers/features/python:1",
887
+ name: "Python"
888
+ },
889
+ {
890
+ category: "language",
891
+ description: "Go compiler and tools",
892
+ id: "ghcr.io/devcontainers/features/go:1",
893
+ name: "Go"
894
+ },
895
+ {
896
+ category: "language",
897
+ description: "Rust toolchain via rustup",
898
+ id: "ghcr.io/devcontainers/features/rust:1",
899
+ name: "Rust"
900
+ },
901
+ {
902
+ category: "language",
903
+ description: "Java runtime and JDK via SDKMAN",
904
+ id: "ghcr.io/devcontainers/features/java:1",
905
+ name: "Java"
906
+ },
907
+ {
908
+ category: "language",
909
+ description: ".NET SDK and runtime",
910
+ id: "ghcr.io/devcontainers/features/dotnet:2",
911
+ name: ".NET"
912
+ },
913
+ // Tools
914
+ {
915
+ category: "tool",
916
+ description: "Common utilities: zsh, Oh My Zsh, git, curl, etc.",
917
+ id: "ghcr.io/devcontainers/features/common-utils:2",
918
+ name: "Common Utilities"
919
+ },
920
+ {
921
+ category: "tool",
922
+ description: "Git version control",
923
+ id: "ghcr.io/devcontainers/features/git:1",
924
+ name: "Git"
925
+ },
926
+ {
927
+ category: "tool",
928
+ description: "Git Large File Storage support",
929
+ id: "ghcr.io/devcontainers/features/git-lfs:1",
930
+ name: "Git LFS"
931
+ },
932
+ {
933
+ category: "tool",
934
+ description: "GitHub CLI for repository management",
935
+ id: "ghcr.io/devcontainers/features/github-cli:1",
936
+ name: "GitHub CLI"
937
+ },
938
+ {
939
+ category: "tool",
940
+ description: "Run Docker containers inside the dev container",
941
+ id: "ghcr.io/devcontainers/features/docker-in-docker:2",
942
+ name: "Docker-in-Docker"
943
+ },
944
+ {
945
+ category: "tool",
946
+ description: "Access host Docker daemon from inside the container",
947
+ id: "ghcr.io/devcontainers/features/docker-outside-of-docker:1",
948
+ name: "Docker-from-Docker"
949
+ },
950
+ {
951
+ category: "tool",
952
+ description: "kubectl, Helm, and Minikube for Kubernetes",
953
+ id: "ghcr.io/devcontainers/features/kubectl-helm-minikube:1",
954
+ name: "Kubernetes Tools"
955
+ },
956
+ {
957
+ category: "tool",
958
+ description: "Infrastructure as code with Terraform",
959
+ id: "ghcr.io/devcontainers/features/terraform:1",
960
+ name: "Terraform"
961
+ },
962
+ {
963
+ category: "tool",
964
+ description: "Nix package manager",
965
+ id: "ghcr.io/devcontainers/features/nix:1",
966
+ name: "Nix"
967
+ },
968
+ {
969
+ category: "tool",
970
+ description: "SSH server for remote connections to the container",
971
+ id: "ghcr.io/devcontainers/features/sshd:1",
972
+ name: "SSH Server"
973
+ },
974
+ // Cloud
975
+ {
976
+ category: "cloud",
977
+ description: "Amazon Web Services CLI v2",
978
+ id: "ghcr.io/devcontainers/features/aws-cli:1",
979
+ name: "AWS CLI"
980
+ },
981
+ {
982
+ category: "cloud",
983
+ description: "Microsoft Azure CLI",
984
+ id: "ghcr.io/devcontainers/features/azure-cli:1",
985
+ name: "Azure CLI"
986
+ },
987
+ {
988
+ category: "cloud",
989
+ description: "Google Cloud Platform CLI",
990
+ id: "ghcr.io/devcontainers/features/gcloud:1",
991
+ name: "Google Cloud CLI"
992
+ },
993
+ // Databases
994
+ {
995
+ category: "database",
996
+ description: "PostgreSQL client tools",
997
+ id: "ghcr.io/devcontainers-extra/features/postgres-client:1",
998
+ name: "PostgreSQL Client"
999
+ },
1000
+ {
1001
+ category: "database",
1002
+ description: "Redis client tools",
1003
+ id: "ghcr.io/devcontainers-extra/features/redis-client:1",
1004
+ name: "Redis Client"
1005
+ }
1006
+ ];
1007
+
1008
+ const filterFeatures = (searchText) => {
1009
+ if (!searchText) {
1010
+ return FEATURE_CATALOG;
1011
+ }
1012
+ const lower = searchText.toLowerCase();
1013
+ return FEATURE_CATALOG.filter(
1014
+ (f) => f.name.toLowerCase().includes(lower) || f.id.toLowerCase().includes(lower) || f.description.toLowerCase().includes(lower)
1015
+ );
1016
+ };
1017
+ const filterExtensions = (searchText) => {
1018
+ if (!searchText) {
1019
+ return EXTENSION_CATALOG;
1020
+ }
1021
+ const lower = searchText.toLowerCase();
1022
+ return EXTENSION_CATALOG.filter(
1023
+ (e) => e.name.toLowerCase().includes(lower) || e.id.toLowerCase().includes(lower) || e.description.toLowerCase().includes(lower)
1024
+ );
1025
+ };
1026
+
1027
+ const FIELDS$1 = ["dockerComposeFile", "service"];
1028
+ const FIELD_LABELS$1 = {
1029
+ dockerComposeFile: "Compose File",
1030
+ service: "Service"
1031
+ };
1032
+ const FIELD_PLACEHOLDERS$1 = {
1033
+ dockerComposeFile: "docker-compose.yml",
1034
+ service: "app"
1035
+ };
1036
+ const FIELD_DESCRIPTIONS = {
1037
+ dockerComposeFile: "Path to Docker Compose file (relative to .devcontainer/)",
1038
+ service: "Which service in the compose file to connect the IDE to"
1039
+ };
1040
+ const DockerComposeSection = ({ config, fieldEditing, fieldIndex, onUpdate }) => {
1041
+ const hasCompose = Boolean(config.dockerComposeFile);
1042
+ const hasImage = Boolean(config.image || config.build);
1043
+ return jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1044
+ jsx(Box, { marginBottom: 1, children: jsx(Text, { bold: true, color: "cyan", children: "Docker Compose Integration" }) }),
1045
+ hasImage && hasCompose && jsx(Box, { marginBottom: 1, children: jsx(Text, { color: "yellow", children: "Note: When using Docker Compose, the image/build settings in General are ignored." }) }),
1046
+ FIELDS$1.map((field, index) => {
1047
+ const isSelected = index === fieldIndex;
1048
+ const value = config[field] ?? "";
1049
+ const displayValue = Array.isArray(config[field]) ? config[field].join(", ") : value;
1050
+ return jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
1051
+ jsxs(Box, { children: [
1052
+ jsx(Box, { width: 20, children: jsxs(Text, { bold: isSelected, color: isSelected ? "cyan" : "white", children: [
1053
+ isSelected ? "❯ " : " ",
1054
+ FIELD_LABELS$1[field],
1055
+ ":"
1056
+ ] }) }),
1057
+ jsx(Box, { flexGrow: 1, children: isSelected && fieldEditing ? jsx(
1058
+ TextInput,
1059
+ {
1060
+ defaultValue: displayValue,
1061
+ onChange: (newValue) => {
1062
+ onUpdate({ [field]: newValue || void 0 });
1063
+ },
1064
+ placeholder: FIELD_PLACEHOLDERS$1[field]
1065
+ }
1066
+ ) : jsx(Text, { color: displayValue ? "white" : "gray", children: displayValue || FIELD_PLACEHOLDERS$1[field] }) })
1067
+ ] }),
1068
+ jsx(Box, { paddingLeft: 4, children: jsx(Text, { dimColor: true, children: FIELD_DESCRIPTIONS[field] }) })
1069
+ ] }, field);
1070
+ }),
1071
+ jsx(Box, { marginTop: 1, children: jsxs(Text, { dimColor: true, children: [
1072
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
1073
+ " ",
1074
+ "edit field",
1075
+ " ",
1076
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
1077
+ " ",
1078
+ "navigate",
1079
+ " ",
1080
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
1081
+ " ",
1082
+ "stop editing"
1083
+ ] }) })
1084
+ ] });
1085
+ };
1086
+ const COMPOSE_FIELD_COUNT = FIELDS$1.length;
1087
+
1088
+ const EnvironmentSection = ({ config, fieldIndex }) => {
1089
+ const containerEnv = config.containerEnv ?? {};
1090
+ const remoteEnv = config.remoteEnv ?? {};
1091
+ const containerKeys = Object.keys(containerEnv);
1092
+ const remoteKeys = Object.keys(remoteEnv);
1093
+ const containerAddIndex = containerKeys.length;
1094
+ const remoteStart = containerKeys.length + 1;
1095
+ const remoteAddIndex = remoteStart + remoteKeys.length;
1096
+ const isInContainerSection = fieldIndex <= containerAddIndex;
1097
+ const isInRemoteSection = fieldIndex > containerAddIndex;
1098
+ return jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1099
+ jsxs(Box, { borderColor: isInContainerSection ? "cyan" : "gray", borderStyle: "single", flexDirection: "column", paddingX: 1, paddingY: 0, children: [
1100
+ jsxs(Box, { flexShrink: 0, marginBottom: containerKeys.length > 0 ? 1 : 0, children: [
1101
+ jsx(Text, { bold: true, color: isInContainerSection ? "cyan" : "white", children: "containerEnv" }),
1102
+ jsx(Text, { dimColor: true, children: " — baked into the container image" })
1103
+ ] }),
1104
+ containerKeys.map((key, index) => {
1105
+ const isSelected = index === fieldIndex;
1106
+ return jsx(Box, { flexShrink: 0, children: jsxs(Text, { color: isSelected ? "cyan" : void 0, inverse: isSelected, wrap: "truncate", children: [
1107
+ isSelected ? " ❯ " : " ",
1108
+ jsx(Text, { bold: true, children: key }),
1109
+ jsx(Text, { dimColor: true, children: " = " }),
1110
+ jsx(Text, { children: containerEnv[key] })
1111
+ ] }) }, key);
1112
+ }),
1113
+ jsx(Box, { flexShrink: 0, marginTop: containerKeys.length > 0 ? 1 : 0, children: jsxs(Text, { color: fieldIndex === containerAddIndex ? "cyan" : "gray", inverse: fieldIndex === containerAddIndex, children: [
1114
+ " ",
1115
+ "+ Add variable..."
1116
+ ] }) })
1117
+ ] }),
1118
+ jsxs(Box, { borderColor: isInRemoteSection ? "cyan" : "gray", borderStyle: "single", flexDirection: "column", marginTop: 1, paddingX: 1, paddingY: 0, children: [
1119
+ jsxs(Box, { flexShrink: 0, marginBottom: remoteKeys.length > 0 ? 1 : 0, children: [
1120
+ jsx(Text, { bold: true, color: isInRemoteSection ? "cyan" : "white", children: "remoteEnv" }),
1121
+ jsx(Text, { dimColor: true, children: " — set at runtime by the IDE" })
1122
+ ] }),
1123
+ remoteKeys.map((key, remoteIdx) => {
1124
+ const actualIndex = remoteStart + remoteIdx;
1125
+ const isSelected = actualIndex === fieldIndex;
1126
+ return jsx(Box, { flexShrink: 0, children: jsxs(Text, { color: isSelected ? "cyan" : void 0, inverse: isSelected, wrap: "truncate", children: [
1127
+ isSelected ? " ❯ " : " ",
1128
+ jsx(Text, { bold: true, children: key }),
1129
+ jsx(Text, { dimColor: true, children: " = " }),
1130
+ jsx(Text, { children: remoteEnv[key] })
1131
+ ] }) }, key);
1132
+ }),
1133
+ jsx(Box, { flexShrink: 0, marginTop: remoteKeys.length > 0 ? 1 : 0, children: jsxs(Text, { color: fieldIndex === remoteAddIndex ? "cyan" : "gray", inverse: fieldIndex === remoteAddIndex, children: [
1134
+ " ",
1135
+ "+ Add variable..."
1136
+ ] }) })
1137
+ ] }),
1138
+ jsx(Box, { flexShrink: 0, marginTop: 1, children: jsxs(Text, { dimColor: true, wrap: "truncate", children: [
1139
+ jsx(Text, { bold: true, color: "white", children: "a" }),
1140
+ "/",
1141
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
1142
+ " ",
1143
+ "add on + row",
1144
+ " ",
1145
+ jsx(Text, { bold: true, color: "white", children: "d" }),
1146
+ " ",
1147
+ "remove",
1148
+ " ",
1149
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
1150
+ " ",
1151
+ "navigate"
1152
+ ] }) })
1153
+ ] });
1154
+ };
1155
+ const getEnvFieldCount = (config) => {
1156
+ const containerCount = Object.keys(config.containerEnv ?? {}).length;
1157
+ const remoteCount = Object.keys(config.remoteEnv ?? {}).length;
1158
+ return containerCount + 1 + remoteCount + 1;
1159
+ };
1160
+
1161
+ const ExtensionsSection = ({ config, fieldIndex, scrollOffset, searchText, viewportHeight }) => {
1162
+ const enabledExtensions = useMemo(() => new Set(config.customizations?.vscode?.extensions), [config.customizations?.vscode?.extensions]);
1163
+ const filtered = useMemo(() => filterExtensions(searchText), [searchText]);
1164
+ const contentHeight = filtered.length;
1165
+ const showScrollbar = contentHeight > viewportHeight && viewportHeight > 0;
1166
+ return jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [
1167
+ jsxs(Box, { flexShrink: 0, gap: 1, paddingX: 1, children: [
1168
+ jsxs(Text, { bold: true, color: "cyan", children: [
1169
+ enabledExtensions.size,
1170
+ " selected"
1171
+ ] }),
1172
+ searchText && jsxs(Text, { dimColor: true, children: [
1173
+ "— filter: ",
1174
+ jsx(Text, { color: "yellow", children: searchText }),
1175
+ " (",
1176
+ filtered.length,
1177
+ " results)"
1178
+ ] })
1179
+ ] }),
1180
+ jsxs(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden", children: [
1181
+ jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingLeft: 1, children: jsx(Box, { flexDirection: "column", marginTop: -scrollOffset, children: filtered.map((ext, index) => {
1182
+ const isSelected = index === fieldIndex;
1183
+ const isEnabled = enabledExtensions.has(ext.id);
1184
+ return jsxs(Box, { flexShrink: 0, height: 1, children: [
1185
+ jsx(Text, { children: isSelected ? ">" : " " }),
1186
+ jsxs(Text, { color: isEnabled ? "white" : "gray", children: [
1187
+ " ",
1188
+ isEnabled ? "☑" : "☐",
1189
+ " "
1190
+ ] }),
1191
+ jsx(Box, { flexGrow: 1, children: jsxs(Text, { bold: isSelected, inverse: isSelected, wrap: "truncate", children: [
1192
+ ext.name,
1193
+ jsxs(Text, { dimColor: true, children: [
1194
+ " -",
1195
+ ext.id
1196
+ ] })
1197
+ ] }) })
1198
+ ] }, ext.id);
1199
+ }) }) }),
1200
+ showScrollbar && jsx(Box, { flexShrink: 0, marginLeft: 1, marginRight: 1, children: jsx(ScrollBar, { contentHeight, placement: "inset", scrollOffset, style: "block", viewportHeight }) })
1201
+ ] }),
1202
+ filtered.length === 0 && jsx(Box, { paddingX: 1, children: jsx(Text, { dimColor: true, children: "No extensions match the search." }) })
1203
+ ] });
1204
+ };
1205
+
1206
+ const FeaturesSection = ({ config, fieldIndex, scrollOffset, searchText, viewportHeight }) => {
1207
+ const enabledFeatures = useMemo(() => new Set(Object.keys(config.features ?? {})), [config.features]);
1208
+ const filtered = useMemo(() => filterFeatures(searchText), [searchText]);
1209
+ const contentHeight = filtered.length;
1210
+ const showScrollbar = contentHeight > viewportHeight && viewportHeight > 0;
1211
+ return jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [
1212
+ jsxs(Box, { flexShrink: 0, gap: 1, paddingX: 1, children: [
1213
+ jsxs(Text, { bold: true, color: "cyan", children: [
1214
+ enabledFeatures.size,
1215
+ " selected"
1216
+ ] }),
1217
+ searchText && jsxs(Text, { dimColor: true, children: [
1218
+ "— filter: ",
1219
+ jsx(Text, { color: "yellow", children: searchText }),
1220
+ " (",
1221
+ filtered.length,
1222
+ " results)"
1223
+ ] })
1224
+ ] }),
1225
+ jsxs(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden", children: [
1226
+ jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingLeft: 1, children: jsx(Box, { flexDirection: "column", marginTop: -scrollOffset, children: filtered.map((feature, index) => {
1227
+ const isSelected = index === fieldIndex;
1228
+ const isEnabled = enabledFeatures.has(feature.id);
1229
+ return jsxs(Box, { flexShrink: 0, height: 1, children: [
1230
+ jsx(Text, { children: isSelected ? ">" : " " }),
1231
+ jsxs(Text, { color: isEnabled ? "white" : "gray", children: [
1232
+ " ",
1233
+ isEnabled ? "☑" : "☐",
1234
+ " "
1235
+ ] }),
1236
+ jsx(Box, { flexGrow: 1, children: jsxs(Text, { bold: isSelected, inverse: isSelected, wrap: "truncate", children: [
1237
+ feature.name,
1238
+ jsxs(Text, { dimColor: true, children: [
1239
+ " -",
1240
+ feature.description
1241
+ ] })
1242
+ ] }) })
1243
+ ] }, feature.id);
1244
+ }) }) }),
1245
+ showScrollbar && jsx(Box, { flexShrink: 0, marginLeft: 1, marginRight: 1, children: jsx(ScrollBar, { contentHeight, placement: "inset", scrollOffset, style: "block", viewportHeight }) })
1246
+ ] }),
1247
+ filtered.length === 0 && jsx(Box, { paddingX: 1, children: jsx(Text, { dimColor: true, children: "No features match the search." }) })
1248
+ ] });
1249
+ };
1250
+
1251
+ const FIELDS = ["name", "image", "workspaceFolder", "workspaceMount", "remoteUser", "containerUser", "shutdownAction"];
1252
+ const FIELD_LABELS = {
1253
+ containerUser: "Container User",
1254
+ image: "Image",
1255
+ name: "Name",
1256
+ remoteUser: "Remote User",
1257
+ shutdownAction: "Shutdown Action",
1258
+ workspaceFolder: "Workspace Folder",
1259
+ workspaceMount: "Workspace Mount"
1260
+ };
1261
+ const FIELD_PLACEHOLDERS = {
1262
+ containerUser: "root",
1263
+ image: "mcr.microsoft.com/devcontainers/javascript-node:22",
1264
+ name: "My Dev Container",
1265
+ remoteUser: "node",
1266
+ shutdownAction: "none | stopContainer",
1267
+ workspaceFolder: "/workspaces/${localWorkspaceFolderBasename}",
1268
+ workspaceMount: "source=${localWorkspaceFolder},target=...,type=bind"
1269
+ };
1270
+ const BOOLEAN_FIELDS = ["privileged", "overrideCommand"];
1271
+ const BOOLEAN_LABELS = {
1272
+ overrideCommand: "Override Command",
1273
+ privileged: "Privileged"
1274
+ };
1275
+ const ALL_FIELD_COUNT = FIELDS.length + BOOLEAN_FIELDS.length;
1276
+ const GeneralSection = ({ config, fieldEditing, fieldIndex, onUpdate }) => jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1277
+ jsx(Box, { marginBottom: 1, children: jsx(Text, { bold: true, color: "cyan", children: "General Configuration" }) }),
1278
+ FIELDS.map((field, index) => {
1279
+ const isSelected = index === fieldIndex;
1280
+ const value = config[field] ?? "";
1281
+ return jsxs(Box, { marginBottom: 1, children: [
1282
+ jsx(Box, { width: 20, children: jsxs(Text, { bold: isSelected, color: isSelected ? "cyan" : "white", children: [
1283
+ isSelected ? "❯ " : " ",
1284
+ FIELD_LABELS[field],
1285
+ ":"
1286
+ ] }) }),
1287
+ jsx(Box, { flexGrow: 1, children: isSelected && fieldEditing ? jsx(
1288
+ TextInput,
1289
+ {
1290
+ defaultValue: value,
1291
+ onChange: (newValue) => {
1292
+ onUpdate({ [field]: newValue });
1293
+ },
1294
+ placeholder: FIELD_PLACEHOLDERS[field]
1295
+ }
1296
+ ) : jsx(Text, { color: value ? "white" : "gray", children: value || FIELD_PLACEHOLDERS[field] }) })
1297
+ ] }, field);
1298
+ }),
1299
+ BOOLEAN_FIELDS.map((field, boolIndex) => {
1300
+ const absoluteIndex = FIELDS.length + boolIndex;
1301
+ const isSelected = absoluteIndex === fieldIndex;
1302
+ const value = config[field] ?? false;
1303
+ return jsxs(Box, { marginBottom: boolIndex < BOOLEAN_FIELDS.length - 1 ? 1 : 0, children: [
1304
+ jsx(Box, { width: 20, children: jsxs(Text, { bold: isSelected, color: isSelected ? "cyan" : "white", children: [
1305
+ isSelected ? "❯ " : " ",
1306
+ BOOLEAN_LABELS[field],
1307
+ ":"
1308
+ ] }) }),
1309
+ jsx(Box, { flexGrow: 1, children: jsxs(Text, { color: value ? "green" : "gray", children: [
1310
+ value ? "yes" : "no",
1311
+ isSelected && jsx(Text, { dimColor: true, children: " (Space to toggle)" })
1312
+ ] }) })
1313
+ ] }, field);
1314
+ }),
1315
+ jsx(Box, { marginTop: 1, children: jsxs(Text, { dimColor: true, children: [
1316
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
1317
+ " ",
1318
+ "edit field",
1319
+ " ",
1320
+ jsx(Text, { bold: true, color: "white", children: "Space" }),
1321
+ " ",
1322
+ "toggle",
1323
+ " ",
1324
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
1325
+ " ",
1326
+ "navigate",
1327
+ " ",
1328
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
1329
+ " ",
1330
+ "stop editing"
1331
+ ] }) })
1332
+ ] });
1333
+ const GENERAL_FIELD_COUNT = ALL_FIELD_COUNT;
1334
+ const GENERAL_BOOLEAN_FIELDS = BOOLEAN_FIELDS;
1335
+
1336
+ const HOOKS = ["postCreateCommand", "postStartCommand", "postAttachCommand", "onCreateCommand"];
1337
+ const HOOK_LABELS = {
1338
+ onCreateCommand: "On Create",
1339
+ postAttachCommand: "Post Attach",
1340
+ postCreateCommand: "Post Create",
1341
+ postStartCommand: "Post Start"
1342
+ };
1343
+ const HOOK_DESCRIPTIONS = {
1344
+ onCreateCommand: "Runs once when the container is first created",
1345
+ postAttachCommand: "Runs each time the IDE attaches",
1346
+ postCreateCommand: "Runs after the container is created and workspace mounted",
1347
+ postStartCommand: "Runs each time the container starts"
1348
+ };
1349
+ const LifecycleSection = ({ config, fieldEditing, fieldIndex, onSetCommand }) => jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1350
+ jsx(Box, { marginBottom: 1, children: jsx(Text, { bold: true, color: "cyan", children: "Lifecycle Commands" }) }),
1351
+ HOOKS.map((hook, index) => {
1352
+ const isSelected = index === fieldIndex;
1353
+ const value = config[hook];
1354
+ const displayValue = Array.isArray(value) ? value.join(" && ") : value ?? "";
1355
+ return jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
1356
+ jsx(Box, { children: jsxs(Text, { bold: isSelected, color: isSelected ? "cyan" : "white", children: [
1357
+ isSelected ? "❯ " : " ",
1358
+ HOOK_LABELS[hook]
1359
+ ] }) }),
1360
+ jsx(Box, { paddingLeft: 4, children: jsx(Text, { dimColor: true, children: HOOK_DESCRIPTIONS[hook] }) }),
1361
+ jsx(Box, { paddingLeft: 4, children: isSelected && fieldEditing ? jsx(
1362
+ TextInput,
1363
+ {
1364
+ defaultValue: displayValue,
1365
+ onChange: (newValue) => {
1366
+ onSetCommand(hook, newValue);
1367
+ },
1368
+ placeholder: "e.g. npm install"
1369
+ }
1370
+ ) : jsx(Text, { color: displayValue ? "green" : "gray", children: displayValue || "(not set)" }) })
1371
+ ] }, hook);
1372
+ }),
1373
+ jsx(Box, { marginTop: 1, children: jsxs(Text, { dimColor: true, children: [
1374
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
1375
+ " ",
1376
+ "edit command",
1377
+ " ",
1378
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
1379
+ " ",
1380
+ "navigate",
1381
+ " ",
1382
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
1383
+ " ",
1384
+ "stop editing"
1385
+ ] }) })
1386
+ ] });
1387
+ const LIFECYCLE_FIELD_COUNT = HOOKS.length;
1388
+
1389
+ const formatMount = (mount) => {
1390
+ if (typeof mount === "string") {
1391
+ return mount;
1392
+ }
1393
+ return `[${mount.type}] ${mount.source} → ${mount.target}`;
1394
+ };
1395
+ const MountsSection = ({
1396
+ addingMount,
1397
+ config,
1398
+ detectedPm,
1399
+ fieldIndex,
1400
+ mountPhase,
1401
+ mountSource,
1402
+ mountTarget,
1403
+ mountType,
1404
+ suggestedMounts
1405
+ }) => {
1406
+ const mounts = config.mounts ?? [];
1407
+ return jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1408
+ jsxs(Box, { flexShrink: 0, gap: 1, paddingX: 1, children: [
1409
+ jsxs(Text, { bold: true, color: "cyan", children: [
1410
+ mounts.length,
1411
+ " mounts"
1412
+ ] }),
1413
+ detectedPm && jsxs(Text, { dimColor: true, children: [
1414
+ "— detected: ",
1415
+ jsx(Text, { color: "white", children: detectedPm })
1416
+ ] })
1417
+ ] }),
1418
+ suggestedMounts.length > 0 && !addingMount && jsxs(Box, { borderColor: "yellow", borderStyle: "single", flexDirection: "column", marginBottom: 1, marginTop: 1, paddingX: 1, children: [
1419
+ jsxs(Box, { flexShrink: 0, children: [
1420
+ jsx(Text, { bold: true, color: "yellow", children: "Suggested mounts" }),
1421
+ jsxs(Text, { dimColor: true, children: [
1422
+ " ",
1423
+ "— press",
1424
+ " ",
1425
+ jsx(Text, { bold: true, color: "white", children: "A" }),
1426
+ " ",
1427
+ "to add all"
1428
+ ] })
1429
+ ] }),
1430
+ suggestedMounts.map((mount, index) => jsx(Box, { flexShrink: 0, children: jsxs(Text, { dimColor: true, wrap: "truncate", children: [
1431
+ " + ",
1432
+ formatMount(mount)
1433
+ ] }) }, `suggestion-${String(index)}`))
1434
+ ] }),
1435
+ mounts.length > 0 && jsx(Box, { flexDirection: "column", marginBottom: 1, children: mounts.map((mount, index) => {
1436
+ const isSelected = index === fieldIndex;
1437
+ return jsxs(Box, { flexShrink: 0, height: 1, children: [
1438
+ jsx(Text, { children: isSelected ? ">" : " " }),
1439
+ jsx(Box, { flexGrow: 1, children: jsxs(Text, { bold: isSelected, inverse: isSelected, wrap: "truncate", children: [
1440
+ " ",
1441
+ formatMount(mount)
1442
+ ] }) })
1443
+ ] }, `mount-${String(index)}`);
1444
+ }) }),
1445
+ !addingMount && jsx(Box, { flexShrink: 0, children: jsxs(Text, { color: fieldIndex === mounts.length ? "cyan" : "gray", inverse: fieldIndex === mounts.length, children: [
1446
+ " ",
1447
+ "+ Add mount..."
1448
+ ] }) }),
1449
+ addingMount && jsxs(Box, { borderColor: "cyan", borderStyle: "single", flexDirection: "column", marginTop: 1, paddingX: 1, children: [
1450
+ jsx(Box, { flexShrink: 0, marginBottom: 1, children: jsx(Text, { bold: true, color: "cyan", children: "New Mount" }) }),
1451
+ jsxs(Box, { flexShrink: 0, children: [
1452
+ jsx(Box, { width: 12, children: jsxs(Text, { bold: mountPhase === "source", color: mountPhase === "source" ? "cyan" : "white", children: [
1453
+ mountPhase === "source" ? "❯ " : " ",
1454
+ "Source:"
1455
+ ] }) }),
1456
+ jsx(Text, { color: mountSource ? "yellow" : "gray", children: mountSource || (mountPhase === "source" ? "_" : "(type source, Enter to continue)") })
1457
+ ] }),
1458
+ jsxs(Box, { flexShrink: 0, children: [
1459
+ jsx(Box, { width: 12, children: jsxs(Text, { bold: mountPhase === "target", color: mountPhase === "target" ? "cyan" : "white", children: [
1460
+ mountPhase === "target" ? "❯ " : " ",
1461
+ "Target:"
1462
+ ] }) }),
1463
+ jsx(Text, { color: mountTarget ? "yellow" : "gray", children: mountTarget || (mountPhase === "target" ? "_" : "/container/path") })
1464
+ ] }),
1465
+ jsxs(Box, { flexShrink: 0, children: [
1466
+ jsx(Box, { width: 12, children: jsxs(Text, { bold: mountPhase === "type", color: mountPhase === "type" ? "cyan" : "white", children: [
1467
+ mountPhase === "type" ? "❯ " : " ",
1468
+ "Type:"
1469
+ ] }) }),
1470
+ mountPhase === "type" ? jsxs(Text, { children: [
1471
+ jsx(Text, { bold: mountType === "volume", color: mountType === "volume" ? "cyan" : "gray", children: "[1] volume" }),
1472
+ " ",
1473
+ jsx(Text, { bold: mountType === "bind", color: mountType === "bind" ? "cyan" : "gray", children: "[2] bind" }),
1474
+ " ",
1475
+ jsx(Text, { bold: mountType === "tmpfs", color: mountType === "tmpfs" ? "cyan" : "gray", children: "[3] tmpfs" })
1476
+ ] }) : jsx(Text, { color: "gray", children: mountType })
1477
+ ] }),
1478
+ jsx(Box, { flexShrink: 0, marginTop: 1, children: jsx(Text, { dimColor: true, wrap: "truncate", children: mountPhase === "type" ? "1/2/3 select type, Enter confirm, Esc cancel" : "Type text, Enter next step, Esc cancel" }) })
1479
+ ] }),
1480
+ mounts.length === 0 && !addingMount && suggestedMounts.length === 0 && jsx(Box, { marginTop: 1, children: jsx(Text, { dimColor: true, children: "Tip: Use volume mounts for node_modules and caches to improve performance." }) })
1481
+ ] });
1482
+ };
1483
+
1484
+ const PortsSection = ({ addingPort, addPortValue, config, fieldIndex }) => {
1485
+ const ports = config.forwardPorts ?? [];
1486
+ const isAddRowSelected = fieldIndex === ports.length;
1487
+ return jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
1488
+ jsxs(Box, { marginBottom: 1, children: [
1489
+ jsx(Text, { bold: true, color: "cyan", children: "Forwarded Ports" }),
1490
+ jsxs(Text, { dimColor: true, children: [
1491
+ " (",
1492
+ ports.length,
1493
+ " ports)"
1494
+ ] })
1495
+ ] }),
1496
+ ports.map((port, index) => {
1497
+ const isSelected = index === fieldIndex;
1498
+ return jsx(Box, { children: jsxs(Text, { color: isSelected ? "cyan" : void 0, inverse: isSelected, children: [
1499
+ " ",
1500
+ String(port)
1501
+ ] }) }, `port-${String(port)}`);
1502
+ }),
1503
+ jsx(Box, { marginTop: ports.length > 0 ? 1 : 0, children: jsxs(Text, { color: isAddRowSelected ? "cyan" : "gray", inverse: isAddRowSelected, children: [
1504
+ " ",
1505
+ isAddRowSelected && addingPort ? jsxs(Text, { children: [
1506
+ "Enter port: ",
1507
+ jsx(Text, { color: "yellow", children: addPortValue || "_" })
1508
+ ] }) : "+ Add port..."
1509
+ ] }) }),
1510
+ jsx(Box, { marginTop: 1, children: jsxs(Text, { dimColor: true, children: [
1511
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
1512
+ " ",
1513
+ isAddRowSelected ? "type port number, Enter to confirm" : "select",
1514
+ " ",
1515
+ jsx(Text, { bold: true, color: "white", children: "d" }),
1516
+ " ",
1517
+ "remove selected",
1518
+ " ",
1519
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
1520
+ " ",
1521
+ "navigate"
1522
+ ] }) })
1523
+ ] });
1524
+ };
1525
+
1526
+ const PreviewPanel = ({ focused, hadComments, jsonPreview, mode, scrollRef }) => jsxs(Box, { borderColor: focused ? "cyan" : "gray", borderStyle: "single", flexDirection: "column", flexGrow: 1, children: [
1527
+ jsxs(Box, { flexShrink: 0, paddingX: 1, children: [
1528
+ jsx(Text, { bold: true, color: focused ? "cyan" : "white", children: "Preview" }),
1529
+ jsxs(Text, { dimColor: true, children: [
1530
+ " (",
1531
+ mode === "create" ? "new" : "edit",
1532
+ ")"
1533
+ ] })
1534
+ ] }),
1535
+ hadComments && mode === "edit" && jsx(Box, { flexShrink: 0, paddingX: 1, children: jsx(Text, { color: "yellow", children: "Comments will not be preserved." }) }),
1536
+ jsx(ScrollView, { flexGrow: 1, ref: scrollRef, scrollbar: true, scrollbarColor: "gray", children: jsonPreview.split("\n").map((line, index) => jsx(Text, { color: "green", children: line }, `line-${String(index)}`)) })
1537
+ ] });
1538
+
1539
+ const validateConfig = (config) => {
1540
+ const errors = [];
1541
+ const warnings = [];
1542
+ const suggestions = [];
1543
+ if (!config.image && !config.build && !config.dockerComposeFile) {
1544
+ errors.push({ field: "image", message: 'One of "image", "build", or "dockerComposeFile" is required' });
1545
+ }
1546
+ if (config.build) {
1547
+ if (config.image) {
1548
+ warnings.push({ field: "image", message: 'Both "image" and "build" are set; "build" takes precedence' });
1549
+ }
1550
+ if (!config.build.dockerfile) {
1551
+ errors.push({ field: "build.dockerfile", message: '"build" requires a "dockerfile" path' });
1552
+ }
1553
+ }
1554
+ if (config.dockerComposeFile && !config.service) {
1555
+ errors.push({ field: "service", message: '"service" is required when using "dockerComposeFile"' });
1556
+ }
1557
+ if (config.features !== void 0 && (typeof config.features !== "object" || Array.isArray(config.features))) {
1558
+ errors.push({ field: "features", message: '"features" must be an object mapping feature IDs to options' });
1559
+ }
1560
+ if (config.forwardPorts) {
1561
+ if (Array.isArray(config.forwardPorts)) {
1562
+ for (const [index, port] of config.forwardPorts.entries()) {
1563
+ if (typeof port === "number" && (port < 1 || port > 65535)) {
1564
+ errors.push({ field: "forwardPorts", message: `Invalid port ${String(port)} at index ${String(index)}` });
1565
+ }
1566
+ }
1567
+ } else {
1568
+ errors.push({ field: "forwardPorts", message: '"forwardPorts" must be an array' });
1569
+ }
1570
+ }
1571
+ if (config.customizations?.vscode?.extensions && !Array.isArray(config.customizations.vscode.extensions)) {
1572
+ errors.push({ field: "customizations.vscode.extensions", message: "Extensions must be an array" });
1573
+ }
1574
+ if (config.customizations?.vscode?.settings && typeof config.customizations.vscode.settings !== "object") {
1575
+ errors.push({ field: "customizations.vscode.settings", message: "Settings must be an object" });
1576
+ }
1577
+ if (!config.name) {
1578
+ suggestions.push({ field: "name", message: "Consider adding a name for better identification" });
1579
+ }
1580
+ if (!config.features || Object.keys(config.features).length === 0) {
1581
+ suggestions.push({ field: "features", message: "Consider adding features for common tools" });
1582
+ }
1583
+ if (!config.customizations?.vscode?.extensions || config.customizations.vscode.extensions.length === 0) {
1584
+ suggestions.push({ field: "extensions", message: "Consider adding VS Code extensions for your stack" });
1585
+ }
1586
+ if (config.privileged) {
1587
+ warnings.push({ field: "privileged", message: "Running in privileged mode is a security risk" });
1588
+ }
1589
+ return { errors, suggestions, valid: errors.length === 0, warnings };
1590
+ };
1591
+
1592
+ const MIN_VIEWPORT_WIDTH = 80;
1593
+ const MIN_VIEWPORT_HEIGHT = 15;
1594
+ const MIN_SPLIT_WIDTH = 120;
1595
+ const EDITOR_SECTIONS = [
1596
+ { description: "Container name, base image, workspace folder, and user", id: "general", label: "General" },
1597
+ { description: "Installable tools and runtimes (Node, Python, Docker, etc.)", id: "features", label: "Features" },
1598
+ { description: "Ports to forward from the container to your host", id: "ports", label: "Ports" },
1599
+ { description: "Commands to run at different stages of the container lifecycle", id: "lifecycle", label: "Lifecycle" },
1600
+ { description: "VS Code extensions to auto-install in the container", id: "extensions", label: "Extensions" },
1601
+ { description: "Environment variables for the container and IDE", id: "environment", label: "Env" },
1602
+ { description: "Volume and bind mounts for persistent data and caches", id: "mounts", label: "Mounts" },
1603
+ { description: "Docker Compose integration for multi-container setups", id: "compose", label: "Compose" }
1604
+ ];
1605
+ const getFieldCount = (section, config, featureSearch, extensionSearch) => {
1606
+ switch (section) {
1607
+ case "compose": {
1608
+ return COMPOSE_FIELD_COUNT;
1609
+ }
1610
+ case "environment": {
1611
+ return getEnvFieldCount(config);
1612
+ }
1613
+ case "extensions": {
1614
+ return filterExtensions(extensionSearch).length;
1615
+ }
1616
+ case "features": {
1617
+ return filterFeatures(featureSearch).length;
1618
+ }
1619
+ case "general": {
1620
+ return GENERAL_FIELD_COUNT;
1621
+ }
1622
+ case "lifecycle": {
1623
+ return LIFECYCLE_FIELD_COUNT;
1624
+ }
1625
+ case "mounts": {
1626
+ return (config.mounts?.length ?? 0) + 1;
1627
+ }
1628
+ case "ports": {
1629
+ return (config.forwardPorts?.length ?? 0) + 1;
1630
+ }
1631
+ default: {
1632
+ return 0;
1633
+ }
1634
+ }
1635
+ };
1636
+ const VisDevcontainerApp = ({ onSave, store }) => {
1637
+ const { exit } = useApp();
1638
+ const { columns, rows } = useWindowSize();
1639
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
1640
+ const [helpVisible, setHelpVisible] = useState(false);
1641
+ const [quitDialogVisible, setQuitDialogVisible] = useState(false);
1642
+ const [searchActive, setSearchActive] = useState(false);
1643
+ const [saveMessage, setSaveMessage] = useState(null);
1644
+ const [focusedPanel, setFocusedPanel] = useState("editor");
1645
+ const [listScrollOffset, setListScrollOffset] = useState(0);
1646
+ const [addingPort, setAddingPort] = useState(false);
1647
+ const [addPortValue, setAddPortValue] = useState("");
1648
+ const [addingEnv, setAddingEnv] = useState(null);
1649
+ const [addEnvKey, setAddEnvKey] = useState("");
1650
+ const [addEnvValue, setAddEnvValue] = useState("");
1651
+ const [addEnvPhase, setAddEnvPhase] = useState("key");
1652
+ const [addingMount, setAddingMount] = useState(false);
1653
+ const [mountSource, setMountSource] = useState("");
1654
+ const [mountTarget, setMountTarget] = useState("");
1655
+ const [mountType, setMountType] = useState("volume");
1656
+ const [mountPhase, setMountPhase] = useState("source");
1657
+ const helpScrollRef = useRef(null);
1658
+ const previewScrollRef = useRef(null);
1659
+ const saveTimerRef = useRef(null);
1660
+ const mountedRef = useRef(true);
1661
+ useEffect(() => {
1662
+ mountedRef.current = true;
1663
+ return () => {
1664
+ mountedRef.current = false;
1665
+ if (saveTimerRef.current) {
1666
+ clearTimeout(saveTimerRef.current);
1667
+ }
1668
+ };
1669
+ }, []);
1670
+ const fieldCount = getFieldCount(state.section, state.config, state.featureSearch, state.extensionSearch);
1671
+ const listViewportHeight = Math.max(1, rows - 9);
1672
+ useEffect(() => {
1673
+ if (state.section !== "features" && state.section !== "extensions") {
1674
+ return;
1675
+ }
1676
+ setListScrollOffset((current) => {
1677
+ if (state.fieldIndex >= current + listViewportHeight) {
1678
+ return state.fieldIndex - listViewportHeight + 1;
1679
+ }
1680
+ if (state.fieldIndex < current) {
1681
+ return state.fieldIndex;
1682
+ }
1683
+ return current;
1684
+ });
1685
+ }, [state.fieldIndex, state.section, listViewportHeight]);
1686
+ useEffect(() => {
1687
+ setListScrollOffset(0);
1688
+ }, [state.section, state.featureSearch, state.extensionSearch]);
1689
+ const handleSave = useCallback(() => {
1690
+ const cleanConfig = store.cleanConfig();
1691
+ const validation = validateConfig(cleanConfig);
1692
+ if (!validation.valid) {
1693
+ const firstError = validation.errors[0];
1694
+ setSaveMessage(firstError ? `Error: ${firstError.message}` : "Validation failed");
1695
+ if (saveTimerRef.current) {
1696
+ clearTimeout(saveTimerRef.current);
1697
+ }
1698
+ saveTimerRef.current = setTimeout(() => {
1699
+ if (mountedRef.current) {
1700
+ setSaveMessage(null);
1701
+ }
1702
+ }, 3e3);
1703
+ return;
1704
+ }
1705
+ onSave(cleanConfig);
1706
+ store.markClean();
1707
+ const warningCount = validation.warnings.length;
1708
+ setSaveMessage(warningCount > 0 ? `Saved! (${String(warningCount)} warning${warningCount > 1 ? "s" : ""})` : "Saved!");
1709
+ if (saveTimerRef.current) {
1710
+ clearTimeout(saveTimerRef.current);
1711
+ }
1712
+ saveTimerRef.current = setTimeout(() => {
1713
+ if (mountedRef.current) {
1714
+ setSaveMessage(null);
1715
+ }
1716
+ }, 2e3);
1717
+ }, [onSave, store]);
1718
+ useInput(
1719
+ (input, key) => {
1720
+ if (key.downArrow || input === "j") {
1721
+ store.setTemplateIndex(state.templateIndex + 1);
1722
+ } else if (key.upArrow || input === "k") {
1723
+ store.setTemplateIndex(state.templateIndex - 1);
1724
+ } else if (key.return) {
1725
+ const template = TEMPLATES[state.templateIndex];
1726
+ if (template) {
1727
+ store.applyTemplate(template.id);
1728
+ }
1729
+ } else if (key.escape) {
1730
+ store.dismissTemplateSelector();
1731
+ }
1732
+ },
1733
+ { isActive: state.showTemplateSelector }
1734
+ );
1735
+ useInput(
1736
+ (input, key) => {
1737
+ if (key.escape) {
1738
+ setAddingPort(false);
1739
+ setAddPortValue("");
1740
+ return;
1741
+ }
1742
+ if (key.return) {
1743
+ const parsed = Number.parseInt(addPortValue, 10);
1744
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 65535) {
1745
+ store.addPort(parsed);
1746
+ }
1747
+ setAddingPort(false);
1748
+ setAddPortValue("");
1749
+ return;
1750
+ }
1751
+ if (key.backspace) {
1752
+ setAddPortValue((v) => v.slice(0, -1));
1753
+ return;
1754
+ }
1755
+ if (input && /^\d$/u.test(input)) {
1756
+ setAddPortValue((v) => v + input);
1757
+ }
1758
+ },
1759
+ { isActive: addingPort }
1760
+ );
1761
+ useInput(
1762
+ (input, key) => {
1763
+ if (key.escape) {
1764
+ setAddingEnv(null);
1765
+ setAddEnvKey("");
1766
+ setAddEnvValue("");
1767
+ setAddEnvPhase("key");
1768
+ return;
1769
+ }
1770
+ if (key.return) {
1771
+ if (addEnvPhase === "key" && addEnvKey) {
1772
+ setAddEnvPhase("value");
1773
+ return;
1774
+ }
1775
+ if (addEnvPhase === "value" && addEnvKey) {
1776
+ store.addEnvVar(addingEnv, addEnvKey, addEnvValue);
1777
+ setAddingEnv(null);
1778
+ setAddEnvKey("");
1779
+ setAddEnvValue("");
1780
+ setAddEnvPhase("key");
1781
+ return;
1782
+ }
1783
+ }
1784
+ if (key.backspace) {
1785
+ if (addEnvPhase === "key") {
1786
+ setAddEnvKey((v) => v.slice(0, -1));
1787
+ } else {
1788
+ setAddEnvValue((v) => v.slice(0, -1));
1789
+ }
1790
+ return;
1791
+ }
1792
+ if (input && !key.ctrl && !key.meta) {
1793
+ if (addEnvPhase === "key") {
1794
+ setAddEnvKey((v) => v + input);
1795
+ } else {
1796
+ setAddEnvValue((v) => v + input);
1797
+ }
1798
+ }
1799
+ },
1800
+ { isActive: addingEnv !== null }
1801
+ );
1802
+ useInput(
1803
+ (input, key) => {
1804
+ if (key.escape) {
1805
+ setAddingMount(false);
1806
+ setMountSource("");
1807
+ setMountTarget("");
1808
+ setMountPhase("source");
1809
+ return;
1810
+ }
1811
+ if (key.return) {
1812
+ if (mountPhase === "source" && mountSource) {
1813
+ setMountPhase("target");
1814
+ return;
1815
+ }
1816
+ if (mountPhase === "target" && mountTarget) {
1817
+ setMountPhase("type");
1818
+ return;
1819
+ }
1820
+ if (mountPhase === "type") {
1821
+ store.addMount({ source: mountSource, target: mountTarget, type: mountType });
1822
+ setAddingMount(false);
1823
+ setMountSource("");
1824
+ setMountTarget("");
1825
+ setMountPhase("source");
1826
+ return;
1827
+ }
1828
+ }
1829
+ if (mountPhase === "type") {
1830
+ switch (input) {
1831
+ case "1": {
1832
+ setMountType("volume");
1833
+ break;
1834
+ }
1835
+ case "2": {
1836
+ setMountType("bind");
1837
+ break;
1838
+ }
1839
+ case "3": {
1840
+ setMountType("tmpfs");
1841
+ break;
1842
+ }
1843
+ }
1844
+ return;
1845
+ }
1846
+ if (key.backspace) {
1847
+ if (mountPhase === "source") {
1848
+ setMountSource((v) => v.slice(0, -1));
1849
+ } else if (mountPhase === "target") {
1850
+ setMountTarget((v) => v.slice(0, -1));
1851
+ }
1852
+ return;
1853
+ }
1854
+ if (input && !key.ctrl && !key.meta) {
1855
+ if (mountPhase === "source") {
1856
+ setMountSource((v) => v + input);
1857
+ } else if (mountPhase === "target") {
1858
+ setMountTarget((v) => v + input);
1859
+ }
1860
+ }
1861
+ },
1862
+ { isActive: addingMount }
1863
+ );
1864
+ useInput(
1865
+ (input, key) => {
1866
+ if (input === "c" && key.ctrl) {
1867
+ exit();
1868
+ return;
1869
+ }
1870
+ if (quitDialogVisible) {
1871
+ return;
1872
+ }
1873
+ if (helpVisible) {
1874
+ if (key.escape || input === "?") {
1875
+ setHelpVisible(false);
1876
+ } else if (key.downArrow || input === "j") {
1877
+ helpScrollRef.current?.scrollBy(1);
1878
+ } else if (key.upArrow || input === "k") {
1879
+ helpScrollRef.current?.scrollBy(-1);
1880
+ } else if (input === "q") {
1881
+ setHelpVisible(false);
1882
+ setQuitDialogVisible(true);
1883
+ }
1884
+ return;
1885
+ }
1886
+ if (searchActive) {
1887
+ if (key.escape) {
1888
+ setSearchActive(false);
1889
+ if (state.section === "features") {
1890
+ store.setFeatureSearch("");
1891
+ } else {
1892
+ store.setExtensionSearch("");
1893
+ }
1894
+ return;
1895
+ }
1896
+ if (key.return) {
1897
+ setSearchActive(false);
1898
+ return;
1899
+ }
1900
+ if (key.backspace) {
1901
+ if (state.section === "features") {
1902
+ store.setFeatureSearch(state.featureSearch.slice(0, -1));
1903
+ } else {
1904
+ store.setExtensionSearch(state.extensionSearch.slice(0, -1));
1905
+ }
1906
+ return;
1907
+ }
1908
+ if (input && !key.ctrl && !key.meta) {
1909
+ if (state.section === "features") {
1910
+ store.setFeatureSearch(state.featureSearch + input);
1911
+ } else {
1912
+ store.setExtensionSearch(state.extensionSearch + input);
1913
+ }
1914
+ return;
1915
+ }
1916
+ return;
1917
+ }
1918
+ if (state.fieldEditing) {
1919
+ if (key.escape) {
1920
+ store.setFieldEditing(false);
1921
+ return;
1922
+ }
1923
+ if (key.return) {
1924
+ store.setFieldEditing(false);
1925
+ return;
1926
+ }
1927
+ return;
1928
+ }
1929
+ if (input === "?") {
1930
+ setHelpVisible(true);
1931
+ return;
1932
+ }
1933
+ if (input === "q") {
1934
+ if (state.isDirty) {
1935
+ setQuitDialogVisible(true);
1936
+ } else {
1937
+ exit();
1938
+ }
1939
+ return;
1940
+ }
1941
+ if (input === "s") {
1942
+ handleSave();
1943
+ return;
1944
+ }
1945
+ if (key.tab) {
1946
+ setFocusedPanel((p) => p === "editor" ? "preview" : "editor");
1947
+ return;
1948
+ }
1949
+ if (focusedPanel === "preview") {
1950
+ if (key.downArrow || input === "j") {
1951
+ previewScrollRef.current?.scrollBy(1);
1952
+ return;
1953
+ }
1954
+ if (key.upArrow || input === "k") {
1955
+ previewScrollRef.current?.scrollBy(-1);
1956
+ return;
1957
+ }
1958
+ if (key.pageDown) {
1959
+ previewScrollRef.current?.scrollBy(10);
1960
+ return;
1961
+ }
1962
+ if (key.pageUp) {
1963
+ previewScrollRef.current?.scrollBy(-10);
1964
+ return;
1965
+ }
1966
+ if (key.home) {
1967
+ previewScrollRef.current?.scrollToTop();
1968
+ return;
1969
+ }
1970
+ if (key.end) {
1971
+ previewScrollRef.current?.scrollToBottom();
1972
+ return;
1973
+ }
1974
+ if (key.escape) {
1975
+ setFocusedPanel("editor");
1976
+ }
1977
+ return;
1978
+ }
1979
+ if (key.downArrow || input === "j") {
1980
+ if (fieldCount > 0) {
1981
+ store.setFieldIndex(Math.min(state.fieldIndex + 1, fieldCount - 1));
1982
+ }
1983
+ return;
1984
+ }
1985
+ if (key.upArrow || input === "k") {
1986
+ store.setFieldIndex(Math.max(state.fieldIndex - 1, 0));
1987
+ return;
1988
+ }
1989
+ if (key.return) {
1990
+ switch (state.section) {
1991
+ case "compose":
1992
+ case "general":
1993
+ case "lifecycle": {
1994
+ store.setFieldEditing(true);
1995
+ break;
1996
+ }
1997
+ case "environment": {
1998
+ const containerCount = Object.keys(state.config.containerEnv ?? {}).length;
1999
+ const containerAddIndex = containerCount;
2000
+ const remoteAddIndex = containerCount + 1 + Object.keys(state.config.remoteEnv ?? {}).length;
2001
+ if (state.fieldIndex === containerAddIndex) {
2002
+ setAddingEnv("container");
2003
+ setAddEnvKey("");
2004
+ setAddEnvValue("");
2005
+ setAddEnvPhase("key");
2006
+ } else if (state.fieldIndex === remoteAddIndex) {
2007
+ setAddingEnv("remote");
2008
+ setAddEnvKey("");
2009
+ setAddEnvValue("");
2010
+ setAddEnvPhase("key");
2011
+ }
2012
+ break;
2013
+ }
2014
+ case "mounts": {
2015
+ const mounts = state.config.mounts ?? [];
2016
+ if (state.fieldIndex === mounts.length) {
2017
+ setAddingMount(true);
2018
+ setMountSource("");
2019
+ setMountTarget("");
2020
+ setMountType("volume");
2021
+ setMountPhase("source");
2022
+ }
2023
+ break;
2024
+ }
2025
+ case "ports": {
2026
+ const ports = state.config.forwardPorts ?? [];
2027
+ if (state.fieldIndex === ports.length) {
2028
+ setAddingPort(true);
2029
+ setAddPortValue("");
2030
+ }
2031
+ break;
2032
+ }
2033
+ }
2034
+ return;
2035
+ }
2036
+ if (input === " ") {
2037
+ switch (state.section) {
2038
+ case "extensions": {
2039
+ const catalog = filterExtensions(state.extensionSearch);
2040
+ const ext = catalog[state.fieldIndex];
2041
+ if (ext) {
2042
+ store.toggleExtension(ext.id);
2043
+ }
2044
+ break;
2045
+ }
2046
+ case "features": {
2047
+ const catalog = filterFeatures(state.featureSearch);
2048
+ const feature = catalog[state.fieldIndex];
2049
+ if (feature) {
2050
+ store.toggleFeature(feature.id);
2051
+ }
2052
+ break;
2053
+ }
2054
+ case "general": {
2055
+ const stringFieldCount = GENERAL_FIELD_COUNT - GENERAL_BOOLEAN_FIELDS.length;
2056
+ const boolIndex = state.fieldIndex - stringFieldCount;
2057
+ if (boolIndex >= 0 && boolIndex < GENERAL_BOOLEAN_FIELDS.length) {
2058
+ const field = GENERAL_BOOLEAN_FIELDS[boolIndex];
2059
+ store.updateConfig({ [field]: !state.config[field] });
2060
+ }
2061
+ break;
2062
+ }
2063
+ }
2064
+ return;
2065
+ }
2066
+ if (input === "/") {
2067
+ if (state.section === "features" || state.section === "extensions") {
2068
+ setSearchActive(true);
2069
+ }
2070
+ return;
2071
+ }
2072
+ if (input === "A" && state.section === "mounts") {
2073
+ store.applySuggestedMounts();
2074
+ return;
2075
+ }
2076
+ if (input === "a") {
2077
+ if (state.section === "environment") {
2078
+ const containerCount = Object.keys(state.config.containerEnv ?? {}).length;
2079
+ const target = state.fieldIndex <= containerCount ? "container" : "remote";
2080
+ setAddingEnv(target);
2081
+ setAddEnvKey("");
2082
+ setAddEnvValue("");
2083
+ setAddEnvPhase("key");
2084
+ } else if (state.section === "mounts") {
2085
+ setAddingMount(true);
2086
+ setMountSource("");
2087
+ setMountTarget("");
2088
+ setMountType("volume");
2089
+ setMountPhase("source");
2090
+ }
2091
+ return;
2092
+ }
2093
+ if (input === "d") {
2094
+ switch (state.section) {
2095
+ case "environment": {
2096
+ const containerKeys = Object.keys(state.config.containerEnv ?? {});
2097
+ const remoteKeys = Object.keys(state.config.remoteEnv ?? {});
2098
+ if (state.fieldIndex < containerKeys.length) {
2099
+ store.removeEnvVar("container", containerKeys[state.fieldIndex]);
2100
+ if (containerKeys.length === 1) ; else if (state.fieldIndex >= containerKeys.length - 1) {
2101
+ store.setFieldIndex(containerKeys.length - 2);
2102
+ }
2103
+ } else {
2104
+ const remoteIndex = state.fieldIndex - containerKeys.length - 1;
2105
+ if (remoteIndex >= 0 && remoteIndex < remoteKeys.length) {
2106
+ store.removeEnvVar("remote", remoteKeys[remoteIndex]);
2107
+ if (remoteKeys.length === 1) ; else if (remoteIndex >= remoteKeys.length - 1) {
2108
+ store.setFieldIndex(state.fieldIndex - 1);
2109
+ }
2110
+ }
2111
+ }
2112
+ break;
2113
+ }
2114
+ case "mounts": {
2115
+ const mounts = state.config.mounts ?? [];
2116
+ if (state.fieldIndex < mounts.length) {
2117
+ store.removeMount(state.fieldIndex);
2118
+ const newCount = mounts.length - 1;
2119
+ if (state.fieldIndex >= newCount && newCount > 0) {
2120
+ store.setFieldIndex(newCount - 1);
2121
+ }
2122
+ }
2123
+ break;
2124
+ }
2125
+ case "ports": {
2126
+ const ports = state.config.forwardPorts ?? [];
2127
+ if (state.fieldIndex < ports.length) {
2128
+ store.removePort(state.fieldIndex);
2129
+ const newCount = ports.length - 1;
2130
+ if (state.fieldIndex >= newCount && newCount > 0) {
2131
+ store.setFieldIndex(newCount - 1);
2132
+ }
2133
+ }
2134
+ break;
2135
+ }
2136
+ }
2137
+ }
2138
+ },
2139
+ { isActive: !state.showTemplateSelector && !addingPort && addingEnv === null && !addingMount }
2140
+ );
2141
+ const jsonPreview = useMemo(() => store.getJsonPreview(), [state.config]);
2142
+ if (columns < MIN_VIEWPORT_WIDTH || rows < MIN_VIEWPORT_HEIGHT) {
2143
+ return jsx(Box, { alignItems: "center", height: rows, justifyContent: "center", width: columns, children: jsxs(Text, { color: "yellow", children: [
2144
+ "Terminal too small (",
2145
+ columns,
2146
+ "x",
2147
+ rows,
2148
+ "), need ",
2149
+ MIN_VIEWPORT_WIDTH,
2150
+ "x",
2151
+ MIN_VIEWPORT_HEIGHT
2152
+ ] }) });
2153
+ }
2154
+ if (state.showTemplateSelector) {
2155
+ return jsx(Box, { alignItems: "center", flexDirection: "column", height: rows, justifyContent: "center", width: columns, children: jsxs(Box, { borderColor: "cyan", borderStyle: "round", flexDirection: "column", paddingX: 2, paddingY: 1, width: 60, children: [
2156
+ jsx(Box, { justifyContent: "center", marginBottom: 1, children: jsx(Text, { bold: true, color: "cyan", children: "Select a Template" }) }),
2157
+ TEMPLATES.map((template, index) => {
2158
+ const isSelected = index === state.templateIndex;
2159
+ return jsx(Box, { children: jsxs(Text, { color: isSelected ? "cyan" : void 0, inverse: isSelected, children: [
2160
+ isSelected ? " ❯ " : " ",
2161
+ jsx(Text, { bold: isSelected, children: template.name }),
2162
+ jsxs(Text, { dimColor: true, children: [
2163
+ " -",
2164
+ template.description
2165
+ ] })
2166
+ ] }) }, template.id);
2167
+ }),
2168
+ jsx(Box, { justifyContent: "center", marginTop: 1, children: jsxs(Text, { dimColor: true, children: [
2169
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
2170
+ " ",
2171
+ "navigate",
2172
+ " ",
2173
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
2174
+ " ",
2175
+ "select",
2176
+ " ",
2177
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
2178
+ " ",
2179
+ "blank"
2180
+ ] }) })
2181
+ ] }) });
2182
+ }
2183
+ let sectionContent;
2184
+ switch (state.section) {
2185
+ case "compose": {
2186
+ sectionContent = jsx(
2187
+ DockerComposeSection,
2188
+ {
2189
+ config: state.config,
2190
+ fieldEditing: state.fieldEditing,
2191
+ fieldIndex: state.fieldIndex,
2192
+ onUpdate: (partial) => {
2193
+ store.updateConfig(partial);
2194
+ }
2195
+ }
2196
+ );
2197
+ break;
2198
+ }
2199
+ case "environment": {
2200
+ sectionContent = jsxs(Box, { flexDirection: "column", children: [
2201
+ jsx(EnvironmentSection, { config: state.config, fieldIndex: state.fieldIndex }),
2202
+ addingEnv !== null && jsx(Box, { marginTop: 1, paddingX: 1, children: jsxs(Text, { color: "cyan", children: [
2203
+ "Add ",
2204
+ addingEnv,
2205
+ " env:",
2206
+ " ",
2207
+ addEnvPhase === "key" ? jsxs(Text, { children: [
2208
+ "key=",
2209
+ jsx(Text, { color: "yellow", children: addEnvKey || "_" }),
2210
+ " (Enter to set value)"
2211
+ ] }) : jsxs(Text, { children: [
2212
+ addEnvKey,
2213
+ "=",
2214
+ jsx(Text, { color: "yellow", children: addEnvValue || "_" }),
2215
+ " (Enter to confirm, Esc to cancel)"
2216
+ ] })
2217
+ ] }) })
2218
+ ] });
2219
+ break;
2220
+ }
2221
+ case "extensions": {
2222
+ sectionContent = jsx(
2223
+ ExtensionsSection,
2224
+ {
2225
+ config: state.config,
2226
+ fieldIndex: state.fieldIndex,
2227
+ scrollOffset: listScrollOffset,
2228
+ searchText: state.extensionSearch,
2229
+ viewportHeight: listViewportHeight
2230
+ }
2231
+ );
2232
+ break;
2233
+ }
2234
+ case "features": {
2235
+ sectionContent = jsx(
2236
+ FeaturesSection,
2237
+ {
2238
+ config: state.config,
2239
+ fieldIndex: state.fieldIndex,
2240
+ scrollOffset: listScrollOffset,
2241
+ searchText: state.featureSearch,
2242
+ viewportHeight: listViewportHeight
2243
+ }
2244
+ );
2245
+ break;
2246
+ }
2247
+ case "general": {
2248
+ sectionContent = jsx(
2249
+ GeneralSection,
2250
+ {
2251
+ config: state.config,
2252
+ fieldEditing: state.fieldEditing,
2253
+ fieldIndex: state.fieldIndex,
2254
+ onUpdate: (partial) => {
2255
+ store.updateConfig(partial);
2256
+ }
2257
+ }
2258
+ );
2259
+ break;
2260
+ }
2261
+ case "lifecycle": {
2262
+ sectionContent = jsx(
2263
+ LifecycleSection,
2264
+ {
2265
+ config: state.config,
2266
+ fieldEditing: state.fieldEditing,
2267
+ fieldIndex: state.fieldIndex,
2268
+ onSetCommand: (hook, command) => {
2269
+ store.setLifecycleCommand(hook, command);
2270
+ }
2271
+ }
2272
+ );
2273
+ break;
2274
+ }
2275
+ case "mounts": {
2276
+ sectionContent = jsx(
2277
+ MountsSection,
2278
+ {
2279
+ addingMount,
2280
+ config: state.config,
2281
+ detectedPm: state.detectedPm,
2282
+ fieldIndex: state.fieldIndex,
2283
+ mountPhase,
2284
+ mountSource,
2285
+ mountTarget,
2286
+ mountType,
2287
+ suggestedMounts: state.suggestedMounts
2288
+ }
2289
+ );
2290
+ break;
2291
+ }
2292
+ case "ports": {
2293
+ sectionContent = jsx(PortsSection, { addingPort, addPortValue, config: state.config, fieldIndex: state.fieldIndex });
2294
+ break;
2295
+ }
2296
+ default: {
2297
+ sectionContent = jsx(Text, { children: "Unknown section" });
2298
+ }
2299
+ }
2300
+ const footer = jsxs(Box, { borderBottom: false, borderColor: "gray", borderLeft: false, borderRight: false, borderStyle: "single", flexShrink: 0, children: [
2301
+ jsxs(Box, { flexGrow: 1, flexWrap: "wrap", gap: 2, paddingX: 1, children: [
2302
+ jsxs(Box, { gap: 1, children: [
2303
+ jsx(Text, { bold: true, color: "white", children: "q" }),
2304
+ jsx(Text, { dimColor: true, children: "QUIT" })
2305
+ ] }),
2306
+ jsxs(Box, { gap: 1, children: [
2307
+ jsx(Text, { bold: true, color: "white", children: "?" }),
2308
+ jsx(Text, { dimColor: true, children: "HELP" })
2309
+ ] }),
2310
+ jsxs(Box, { gap: 1, children: [
2311
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
2312
+ jsx(Text, { dimColor: true, children: "NAV" })
2313
+ ] }),
2314
+ (state.section === "features" || state.section === "extensions") && jsxs(Box, { gap: 1, children: [
2315
+ jsx(Text, { bold: true, color: "white", children: "Space" }),
2316
+ jsx(Text, { dimColor: true, children: "CHECK" })
2317
+ ] }),
2318
+ jsxs(Box, { gap: 1, children: [
2319
+ jsx(Text, { bold: true, color: "white", children: "←→" }),
2320
+ jsx(Text, { dimColor: true, children: "TABS" })
2321
+ ] }),
2322
+ jsxs(Box, { gap: 1, children: [
2323
+ jsx(Text, { bold: true, color: "white", children: "Tab" }),
2324
+ jsx(Text, { dimColor: true, children: "PANEL" })
2325
+ ] }),
2326
+ (state.section === "features" || state.section === "extensions") && jsxs(Box, { gap: 1, children: [
2327
+ jsx(Text, { bold: true, color: "white", children: "/" }),
2328
+ jsx(Text, { dimColor: true, children: "FILTER" })
2329
+ ] }),
2330
+ jsxs(Box, { gap: 1, children: [
2331
+ jsx(Text, { bold: true, color: "white", children: "s" }),
2332
+ jsx(Text, { dimColor: true, children: "SAVE" })
2333
+ ] })
2334
+ ] }),
2335
+ jsxs(Box, { paddingX: 1, children: [
2336
+ saveMessage && jsxs(Text, { color: saveMessage.startsWith("Error") ? "red" : "green", children: [
2337
+ saveMessage,
2338
+ " "
2339
+ ] }),
2340
+ state.isDirty && jsx(Text, { color: "yellow", children: "[modified]" }),
2341
+ !state.isDirty && !saveMessage && jsx(Text, { dimColor: true, children: "[saved]" })
2342
+ ] })
2343
+ ] });
2344
+ const helpPopup = jsxs(
2345
+ Dialog,
2346
+ {
2347
+ footer: jsxs(Text, { dimColor: true, children: [
2348
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
2349
+ " ",
2350
+ "scroll",
2351
+ " ",
2352
+ jsx(Text, { bold: true, color: "white", children: "?" }),
2353
+ "/",
2354
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
2355
+ " ",
2356
+ "close"
2357
+ ] }),
2358
+ scrollRef: helpScrollRef,
2359
+ title: "KEYBOARD SHORTCUTS",
2360
+ visible: helpVisible,
2361
+ width: 56,
2362
+ children: [
2363
+ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
2364
+ jsxs(Box, { marginBottom: 1, children: [
2365
+ jsx(Text, { dimColor: true, children: "── " }),
2366
+ jsx(Text, { bold: true, color: "white", children: "NAVIGATION" })
2367
+ ] }),
2368
+ jsxs(Text, { children: [
2369
+ " ",
2370
+ jsx(Text, { bold: true, color: "white", children: "←→" }),
2371
+ jsx(Text, { dimColor: true, children: " Switch tabs" })
2372
+ ] }),
2373
+ jsxs(Text, { children: [
2374
+ " ",
2375
+ jsx(Text, { bold: true, color: "white", children: "↑↓" }),
2376
+ "/",
2377
+ jsx(Text, { bold: true, color: "white", children: "j/k" }),
2378
+ jsx(Text, { dimColor: true, children: " Navigate within section" })
2379
+ ] }),
2380
+ jsxs(Text, { children: [
2381
+ " ",
2382
+ jsx(Text, { bold: true, color: "white", children: "Tab" }),
2383
+ jsx(Text, { dimColor: true, children: " Switch editor/preview panel" })
2384
+ ] }),
2385
+ jsxs(Text, { children: [
2386
+ " ",
2387
+ jsx(Text, { bold: true, color: "white", children: "Enter" }),
2388
+ jsx(Text, { dimColor: true, children: " Edit selected field" })
2389
+ ] }),
2390
+ jsxs(Text, { children: [
2391
+ " ",
2392
+ jsx(Text, { bold: true, color: "white", children: "Esc" }),
2393
+ jsx(Text, { dimColor: true, children: " Stop editing / cancel" })
2394
+ ] })
2395
+ ] }),
2396
+ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
2397
+ jsxs(Box, { marginBottom: 1, children: [
2398
+ jsx(Text, { dimColor: true, children: "── " }),
2399
+ jsx(Text, { bold: true, color: "white", children: "FEATURES / EXTENSIONS" })
2400
+ ] }),
2401
+ jsxs(Text, { children: [
2402
+ " ",
2403
+ jsx(Text, { bold: true, color: "white", children: "Space" }),
2404
+ jsx(Text, { dimColor: true, children: " Toggle selection" })
2405
+ ] }),
2406
+ jsxs(Text, { children: [
2407
+ " ",
2408
+ jsx(Text, { bold: true, color: "white", children: "/" }),
2409
+ jsx(Text, { dimColor: true, children: " Search / filter" })
2410
+ ] })
2411
+ ] }),
2412
+ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
2413
+ jsxs(Box, { marginBottom: 1, children: [
2414
+ jsx(Text, { dimColor: true, children: "── " }),
2415
+ jsx(Text, { bold: true, color: "white", children: "LISTS (Ports, Mounts, Env)" })
2416
+ ] }),
2417
+ jsxs(Text, { children: [
2418
+ " ",
2419
+ jsx(Text, { bold: true, color: "white", children: "a" }),
2420
+ jsx(Text, { dimColor: true, children: " Add new entry" })
2421
+ ] }),
2422
+ jsxs(Text, { children: [
2423
+ " ",
2424
+ jsx(Text, { bold: true, color: "white", children: "d" }),
2425
+ jsx(Text, { dimColor: true, children: " Delete selected entry" })
2426
+ ] })
2427
+ ] }),
2428
+ jsxs(Box, { flexDirection: "column", children: [
2429
+ jsxs(Box, { marginBottom: 1, children: [
2430
+ jsx(Text, { dimColor: true, children: "── " }),
2431
+ jsx(Text, { bold: true, color: "white", children: "ACTIONS" })
2432
+ ] }),
2433
+ jsxs(Text, { children: [
2434
+ " ",
2435
+ jsx(Text, { bold: true, color: "white", children: "s" }),
2436
+ jsx(Text, { dimColor: true, children: " Save configuration" })
2437
+ ] }),
2438
+ jsxs(Text, { children: [
2439
+ " ",
2440
+ jsx(Text, { bold: true, color: "white", children: "q" }),
2441
+ jsx(Text, { dimColor: true, children: " Quit" })
2442
+ ] }),
2443
+ jsxs(Text, { children: [
2444
+ " ",
2445
+ jsx(Text, { bold: true, color: "white", children: "?" }),
2446
+ jsx(Text, { dimColor: true, children: " Toggle help" })
2447
+ ] })
2448
+ ] })
2449
+ ]
2450
+ }
2451
+ );
2452
+ const previewPanel = jsx(
2453
+ PreviewPanel,
2454
+ {
2455
+ focused: focusedPanel === "preview",
2456
+ hadComments: state.hadComments,
2457
+ jsonPreview,
2458
+ mode: state.mode,
2459
+ scrollRef: previewScrollRef
2460
+ }
2461
+ );
2462
+ const isSplitLayout = columns >= MIN_SPLIT_WIDTH;
2463
+ const previewWidth = isSplitLayout ? Math.floor(columns * 0.38) : 0;
2464
+ return jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [
2465
+ jsxs(Box, { flexShrink: 0, gap: 1, paddingX: 1, children: [
2466
+ jsx(Text, { bold: true, inverse: true, children: " VIS " }),
2467
+ jsxs(Text, { wrap: "truncate", children: [
2468
+ state.mode === "create" ? "Create" : "Edit",
2469
+ " devcontainer"
2470
+ ] })
2471
+ ] }),
2472
+ jsx(Box, { flexShrink: 0, paddingX: 1, paddingY: 1, children: jsx(
2473
+ Tabs,
2474
+ {
2475
+ defaultValue: state.section,
2476
+ keyMap: { useNumbers: false, useTab: false },
2477
+ onChange: (name) => {
2478
+ store.setSection(name);
2479
+ setFocusedPanel("editor");
2480
+ },
2481
+ showIndex: false,
2482
+ children: EDITOR_SECTIONS.map(({ id, label }) => jsx(Tab, { name: id, children: label }, id))
2483
+ }
2484
+ ) }),
2485
+ jsx(Box, { flexShrink: 0, paddingRight: 2, children: jsx(Text, { dimColor: true, wrap: "truncate", children: EDITOR_SECTIONS.find((s) => s.id === state.section)?.description ?? "" }) }),
2486
+ jsxs(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden", children: [
2487
+ jsx(Box, { borderColor: focusedPanel === "editor" ? "white" : "gray", borderStyle: "single", flexDirection: "column", flexGrow: 1, overflow: "hidden", children: sectionContent }),
2488
+ isSplitLayout && jsx(Box, { flexShrink: 0, width: previewWidth, children: previewPanel })
2489
+ ] }),
2490
+ footer,
2491
+ jsx(
2492
+ QuitDialog,
2493
+ {
2494
+ autoExitSeconds: 3,
2495
+ onCancel: () => {
2496
+ setQuitDialogVisible(false);
2497
+ },
2498
+ visible: quitDialogVisible
2499
+ }
2500
+ ),
2501
+ helpPopup
2502
+ ] });
2503
+ };
2504
+
2505
+ const execute = async ({ logger, options, workspaceRoot: wsRoot }) => {
2506
+ if (!wsRoot) {
2507
+ throw new Error("Could not determine workspace root. Run this command inside a monorepo or project directory.");
2508
+ }
2509
+ const workspaceRoot = wsRoot;
2510
+ const templateId = options.template;
2511
+ const outputPath = options.output;
2512
+ const isTTY = Boolean(process.stdout.isTTY) && !isInCi;
2513
+ let detectedPm = null;
2514
+ try {
2515
+ const pmInfo = detectPm(workspaceRoot);
2516
+ detectedPm = pmInfo.name;
2517
+ } catch {
2518
+ }
2519
+ const existing = readDevcontainerJson(workspaceRoot);
2520
+ let initialConfig = existing?.config ?? null;
2521
+ const hadComments = existing?.hadComments ?? false;
2522
+ if (templateId && !existing) {
2523
+ const template = TEMPLATES.find((t) => t.id === templateId);
2524
+ if (!template) {
2525
+ const validIds = TEMPLATES.map((t) => t.id).join(", ");
2526
+ throw new Error(`Unknown template "${templateId}". Valid templates: ${validIds}`);
2527
+ }
2528
+ initialConfig = template.config;
2529
+ }
2530
+ if (!isTTY) {
2531
+ if (initialConfig) {
2532
+ logger.info(JSON.stringify(initialConfig, null, 2));
2533
+ } else {
2534
+ logger.error("No existing devcontainer.json found. Use --template to generate one in non-TTY mode.");
2535
+ process.exitCode = 1;
2536
+ }
2537
+ return;
2538
+ }
2539
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2540
+ process.stdin.setRawMode(true);
2541
+ process.stdin.ref();
2542
+ process.stdin.resume();
2543
+ }
2544
+ const keepAlive = setInterval(() => {
2545
+ }, 1e3);
2546
+ const store = new DevcontainerStore(initialConfig, hadComments, detectedPm);
2547
+ if (templateId && !existing) {
2548
+ store.dismissTemplateSelector();
2549
+ }
2550
+ let savedConfig = null;
2551
+ const instance = render(
2552
+ React.createElement(VisDevcontainerApp, {
2553
+ onSave: (config) => {
2554
+ writeDevcontainerJson(workspaceRoot, config, outputPath);
2555
+ savedConfig = config;
2556
+ },
2557
+ store
2558
+ }),
2559
+ {
2560
+ alternateScreen: true,
2561
+ exitOnCtrlC: false,
2562
+ interactive: true,
2563
+ patchConsole: true
2564
+ }
2565
+ );
2566
+ await instance.waitUntilExit();
2567
+ clearInterval(keepAlive);
2568
+ if (savedConfig) {
2569
+ const target = outputPath ?? ".devcontainer/devcontainer.json";
2570
+ logger.info(`DevContainer config saved to ${target}`);
2571
+ }
2572
+ };
2573
+
2574
+ export { execute as default };