@fresh-editor/fresh-editor 0.2.25 → 0.3.1

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 (82) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/plugins/astro-lsp.ts +6 -12
  5. package/plugins/audit_mode.i18n.json +14 -14
  6. package/plugins/audit_mode.ts +182 -146
  7. package/plugins/bash-lsp.ts +15 -22
  8. package/plugins/clangd-lsp.ts +15 -24
  9. package/plugins/clojure-lsp.ts +9 -12
  10. package/plugins/cmake-lsp.ts +9 -12
  11. package/plugins/code-tour.ts +15 -16
  12. package/plugins/config-schema.json +79 -6
  13. package/plugins/csharp_support.ts +25 -30
  14. package/plugins/css-lsp.ts +15 -22
  15. package/plugins/dart-lsp.ts +9 -12
  16. package/plugins/dashboard.ts +1903 -0
  17. package/plugins/devcontainer.i18n.json +1472 -0
  18. package/plugins/devcontainer.ts +2793 -0
  19. package/plugins/diagnostics_panel.ts +10 -17
  20. package/plugins/elixir-lsp.ts +9 -12
  21. package/plugins/erlang-lsp.ts +9 -12
  22. package/plugins/examples/bookmarks.ts +10 -16
  23. package/plugins/find_references.ts +5 -9
  24. package/plugins/flash.ts +577 -0
  25. package/plugins/fsharp-lsp.ts +9 -12
  26. package/plugins/git_explorer.ts +16 -20
  27. package/plugins/git_gutter.ts +65 -79
  28. package/plugins/git_log.i18n.json +14 -42
  29. package/plugins/git_log.ts +19 -9
  30. package/plugins/gleam-lsp.ts +9 -12
  31. package/plugins/go-lsp.ts +15 -22
  32. package/plugins/graphql-lsp.ts +9 -12
  33. package/plugins/haskell-lsp.ts +9 -12
  34. package/plugins/html-lsp.ts +15 -24
  35. package/plugins/java-lsp.ts +9 -12
  36. package/plugins/json-lsp.ts +15 -24
  37. package/plugins/julia-lsp.ts +9 -12
  38. package/plugins/kotlin-lsp.ts +15 -22
  39. package/plugins/latex-lsp.ts +9 -12
  40. package/plugins/lib/fresh.d.ts +603 -0
  41. package/plugins/lua-lsp.ts +15 -22
  42. package/plugins/markdown_compose.ts +132 -128
  43. package/plugins/markdown_source.ts +8 -10
  44. package/plugins/marksman-lsp.ts +9 -12
  45. package/plugins/merge_conflict.ts +15 -17
  46. package/plugins/nim-lsp.ts +9 -12
  47. package/plugins/nix-lsp.ts +9 -12
  48. package/plugins/nushell-lsp.ts +9 -12
  49. package/plugins/ocaml-lsp.ts +9 -12
  50. package/plugins/odin-lsp.ts +15 -22
  51. package/plugins/path_complete.ts +5 -6
  52. package/plugins/perl-lsp.ts +9 -12
  53. package/plugins/php-lsp.ts +15 -22
  54. package/plugins/pkg.ts +10 -21
  55. package/plugins/protobuf-lsp.ts +9 -12
  56. package/plugins/python-lsp.ts +15 -24
  57. package/plugins/r-lsp.ts +9 -12
  58. package/plugins/ruby-lsp.ts +15 -22
  59. package/plugins/rust-lsp.ts +18 -28
  60. package/plugins/scala-lsp.ts +9 -12
  61. package/plugins/schemas/theme.schema.json +126 -0
  62. package/plugins/search_replace.ts +10 -13
  63. package/plugins/solidity-lsp.ts +9 -12
  64. package/plugins/sql-lsp.ts +9 -12
  65. package/plugins/svelte-lsp.ts +9 -12
  66. package/plugins/swift-lsp.ts +9 -12
  67. package/plugins/tailwindcss-lsp.ts +9 -12
  68. package/plugins/templ-lsp.ts +9 -12
  69. package/plugins/terraform-lsp.ts +9 -12
  70. package/plugins/theme_editor.i18n.json +98 -14
  71. package/plugins/theme_editor.ts +156 -209
  72. package/plugins/toml-lsp.ts +15 -22
  73. package/plugins/tsconfig.json +100 -0
  74. package/plugins/typescript-lsp.ts +15 -24
  75. package/plugins/typst-lsp.ts +15 -22
  76. package/plugins/vi_mode.ts +77 -290
  77. package/plugins/vue-lsp.ts +9 -12
  78. package/plugins/yaml-lsp.ts +15 -22
  79. package/plugins/zig-lsp.ts +9 -12
  80. package/themes/high-contrast.json +2 -2
  81. package/themes/nord.json +4 -0
  82. package/themes/solarized-dark.json +4 -0
@@ -0,0 +1,2793 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+ /**
5
+ * Dev Container Plugin
6
+ *
7
+ * Detects .devcontainer/devcontainer.json configurations and provides:
8
+ * - Status bar summary of the container environment
9
+ * - Info panel showing image, features, ports, env vars, lifecycle commands
10
+ * - Lifecycle command runner via command palette
11
+ * - Quick open for the devcontainer.json config file
12
+ */
13
+
14
+ // =============================================================================
15
+ // Types
16
+ // =============================================================================
17
+
18
+ interface DevContainerConfig {
19
+ name?: string;
20
+ image?: string;
21
+ build?: {
22
+ dockerfile?: string;
23
+ context?: string;
24
+ args?: Record<string, string>;
25
+ target?: string;
26
+ cacheFrom?: string | string[];
27
+ };
28
+ dockerComposeFile?: string | string[];
29
+ service?: string;
30
+ features?: Record<string, string | boolean | Record<string, unknown>>;
31
+ forwardPorts?: (number | string)[];
32
+ portsAttributes?: Record<string, PortAttributes>;
33
+ appPort?: number | string | (number | string)[];
34
+ containerEnv?: Record<string, string>;
35
+ remoteEnv?: Record<string, string>;
36
+ userEnvProbe?: "none" | "loginShell" | "loginInteractiveShell" | "interactiveShell";
37
+ containerUser?: string;
38
+ remoteUser?: string;
39
+ mounts?: (string | MountConfig)[];
40
+ initializeCommand?: LifecycleCommand;
41
+ onCreateCommand?: LifecycleCommand;
42
+ updateContentCommand?: LifecycleCommand;
43
+ postCreateCommand?: LifecycleCommand;
44
+ postStartCommand?: LifecycleCommand;
45
+ postAttachCommand?: LifecycleCommand;
46
+ customizations?: Record<string, unknown>;
47
+ runArgs?: string[];
48
+ workspaceFolder?: string;
49
+ workspaceMount?: string;
50
+ shutdownAction?: string;
51
+ overrideCommand?: boolean;
52
+ init?: boolean;
53
+ privileged?: boolean;
54
+ capAdd?: string[];
55
+ securityOpt?: string[];
56
+ hostRequirements?: {
57
+ cpus?: number;
58
+ memory?: string;
59
+ storage?: string;
60
+ gpu?: boolean | string | { cores?: number; memory?: string };
61
+ };
62
+ }
63
+
64
+ type LifecycleCommand = string | string[] | Record<string, string | string[]>;
65
+
66
+ interface PortAttributes {
67
+ label?: string;
68
+ protocol?: string;
69
+ onAutoForward?: string;
70
+ requireLocalPort?: boolean;
71
+ elevateIfNeeded?: boolean;
72
+ }
73
+
74
+ interface MountConfig {
75
+ type?: string;
76
+ source?: string;
77
+ target?: string;
78
+ }
79
+
80
+ // =============================================================================
81
+ // State
82
+ // =============================================================================
83
+
84
+ let config: DevContainerConfig | null = null;
85
+ let configPath: string | null = null;
86
+ let infoPanelBufferId: number | null = null;
87
+ let infoPanelSplitId: number | null = null;
88
+ let infoPanelOpen = false;
89
+ let cachedContent = "";
90
+
91
+ /// Single shared panel slot for every devcontainer-owned panel
92
+ /// (Show Info / Show Container Logs / Show Build Logs / Show
93
+ /// Forwarded Ports / lifecycle command output / build-log
94
+ /// streaming / failed-attach error). Without this, each `Show *`
95
+ /// invocation used to call `createVirtualBufferInSplit` directly,
96
+ /// which always creates a new horizontal split — so by the third
97
+ /// invocation the right column was several stacked panes ~5 rows
98
+ /// each, the layout became uninhabitable, and several downstream
99
+ /// L-rows in the usability bug table collapsed into "the layout
100
+ /// strategy is wrong."
101
+ ///
102
+ /// Policy (matches the user's spec for fix #6 on retest):
103
+ /// - If `panelSplitId` is set AND that split still exists in
104
+ /// `editor.listSplits()`, REUSE it: focus + swap content.
105
+ /// - Otherwise, use the currently focused split — *don't*
106
+ /// spawn a new one. The first Show command therefore
107
+ /// replaces whatever is in the focused split (the user's
108
+ /// editor pane is gone if they didn't manually split first;
109
+ /// this is the explicit tradeoff the user picked over the
110
+ /// unbounded-stacking alternative).
111
+ let panelSplitId: number | null = null;
112
+ const panelBufferIds = new Set<number>();
113
+
114
+ // The in-flight `devcontainer up` handle (set before we await, cleared
115
+ // on exit). `devcontainer_cancel_attach` forwards `.kill()` to this.
116
+ // null when no attach is running.
117
+ let attachInFlight: ProcessHandle<SpawnResult> | null = null;
118
+
119
+ // Set by `devcontainer_cancel_attach` right before it kills the
120
+ // in-flight handle; read by `runDevcontainerUp` so the non-zero exit
121
+ // coming out of the kill doesn't also trigger a FailedAttach — the
122
+ // cancel already set the indicator back to Local.
123
+ let attachCancelled = false;
124
+
125
+ // Focus state for info panel buttons (Tab navigation like pkg.ts)
126
+ type InfoFocusTarget = { type: "button"; index: number };
127
+
128
+ interface InfoButton {
129
+ id: string;
130
+ label: string;
131
+ command: string;
132
+ }
133
+
134
+ const infoButtons: InfoButton[] = [
135
+ { id: "run", label: "Run Lifecycle", command: "devcontainer_run_lifecycle" },
136
+ { id: "open", label: "Open Config", command: "devcontainer_open_config" },
137
+ { id: "rebuild", label: "Rebuild", command: "devcontainer_rebuild" },
138
+ { id: "close", label: "Close", command: "devcontainer_close_info" },
139
+ ];
140
+
141
+ let infoFocus: InfoFocusTarget = { type: "button", index: 0 };
142
+
143
+ // =============================================================================
144
+ // Colors
145
+ // =============================================================================
146
+
147
+ const colors = {
148
+ heading: [255, 200, 100] as [number, number, number],
149
+ key: [100, 200, 255] as [number, number, number],
150
+ value: [200, 200, 200] as [number, number, number],
151
+ feature: [150, 255, 150] as [number, number, number],
152
+ port: [255, 180, 100] as [number, number, number],
153
+ footer: [120, 120, 120] as [number, number, number],
154
+ button: [180, 180, 190] as [number, number, number],
155
+ buttonFocused: [255, 255, 255] as [number, number, number],
156
+ buttonFocusedBg: [60, 110, 180] as [number, number, number],
157
+ };
158
+
159
+ // =============================================================================
160
+ // Config Discovery
161
+ // =============================================================================
162
+
163
+ /// Last parse failure observed by `findConfig` — surfaced via
164
+ /// `setStatus` and an action popup at init / on file save so the
165
+ /// user notices broken JSON instead of silently losing every
166
+ /// `Dev Container:` command.
167
+ let lastParseError: { path: string; message: string } | null = null;
168
+
169
+ function tryParse(path: string, content: string): boolean {
170
+ try {
171
+ config = editor.parseJsonc(content) as DevContainerConfig;
172
+ configPath = path;
173
+ lastParseError = null;
174
+ return true;
175
+ } catch (e) {
176
+ const message = e instanceof Error ? e.message : String(e);
177
+ lastParseError = { path, message };
178
+ // Set `configPath` to the broken file so the recovery
179
+ // command set's `Open Config` can route the user there.
180
+ // `config` stays null so callers that depend on parsed
181
+ // fields (rebuild, attach) don't crash.
182
+ configPath = path;
183
+ editor.debug(`devcontainer: failed to parse ${path}: ${message}`);
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function findConfig(): boolean {
189
+ const cwd = editor.getCwd();
190
+ lastParseError = null;
191
+
192
+ // Priority 1: .devcontainer/devcontainer.json
193
+ const primary = editor.pathJoin(cwd, ".devcontainer", "devcontainer.json");
194
+ const primaryContent = editor.readFile(primary);
195
+ if (primaryContent !== null) {
196
+ if (tryParse(primary, primaryContent)) return true;
197
+ }
198
+
199
+ // Priority 2: .devcontainer.json
200
+ const secondary = editor.pathJoin(cwd, ".devcontainer.json");
201
+ const secondaryContent = editor.readFile(secondary);
202
+ if (secondaryContent !== null) {
203
+ if (tryParse(secondary, secondaryContent)) return true;
204
+ }
205
+
206
+ // Priority 3: .devcontainer/<subfolder>/devcontainer.json
207
+ const dcDir = editor.pathJoin(cwd, ".devcontainer");
208
+ if (editor.fileExists(dcDir)) {
209
+ const entries = editor.readDir(dcDir);
210
+ for (const entry of entries) {
211
+ if (entry.is_dir) {
212
+ const subConfig = editor.pathJoin(dcDir, entry.name, "devcontainer.json");
213
+ const subContent = editor.readFile(subConfig);
214
+ if (subContent !== null) {
215
+ if (tryParse(subConfig, subContent)) return true;
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ return false;
222
+ }
223
+
224
+ /// Surface the last parse error (if any) to the user via the status
225
+ /// bar. Idempotent — safe to call repeatedly. Bug #2 (silent JSON
226
+ /// syntax errors): without this, a broken `devcontainer.json`
227
+ /// causes `findConfig` to return false, no commands register,
228
+ /// and the user has no clue why the feature stopped working.
229
+ function showParseErrorIfAny(): void {
230
+ if (!lastParseError) return;
231
+ editor.setStatus(
232
+ editor.t("status.parse_failed", {
233
+ path: lastParseError.path,
234
+ message: lastParseError.message,
235
+ }),
236
+ );
237
+ }
238
+
239
+ // =============================================================================
240
+ // Formatting Helpers
241
+ // =============================================================================
242
+
243
+ function formatLifecycleCommand(cmd: LifecycleCommand): string {
244
+ if (typeof cmd === "string") return cmd;
245
+ if (Array.isArray(cmd)) return cmd.join(" ");
246
+ return Object.entries(cmd)
247
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(" ") : v}`)
248
+ .join("; ");
249
+ }
250
+
251
+ function formatMount(mount: string | MountConfig): string {
252
+ if (typeof mount === "string") return mount;
253
+ const parts: string[] = [];
254
+ if (mount.source) parts.push(mount.source);
255
+ parts.push("->");
256
+ if (mount.target) parts.push(mount.target);
257
+ if (mount.type) parts.push(`(${mount.type})`);
258
+ return parts.join(" ");
259
+ }
260
+
261
+ function getImageSummary(): string {
262
+ if (!config) return "unknown";
263
+ if (config.image) return config.image;
264
+ if (config.build?.dockerfile) return "Dockerfile: " + config.build.dockerfile;
265
+ if (config.dockerComposeFile) return "Compose";
266
+ return "unknown";
267
+ }
268
+
269
+ // =============================================================================
270
+ // Info Panel
271
+ // =============================================================================
272
+
273
+ function buildInfoEntries(): TextPropertyEntry[] {
274
+ if (!config) return [];
275
+
276
+ const entries: TextPropertyEntry[] = [];
277
+
278
+ // Header
279
+ const name = config.name ?? "unnamed";
280
+ entries.push({
281
+ text: editor.t("panel.header", { name }) + "\n",
282
+ properties: { type: "heading" },
283
+ });
284
+ entries.push({ text: "\n", properties: { type: "blank" } });
285
+
286
+ // Image / Build / Compose
287
+ if (config.image) {
288
+ entries.push({ text: editor.t("panel.section_image") + "\n", properties: { type: "heading" } });
289
+ entries.push({ text: " " + config.image + "\n", properties: { type: "value" } });
290
+ entries.push({ text: "\n", properties: { type: "blank" } });
291
+ } else if (config.build?.dockerfile) {
292
+ entries.push({ text: editor.t("panel.section_build") + "\n", properties: { type: "heading" } });
293
+ entries.push({ text: " dockerfile: " + config.build.dockerfile + "\n", properties: { type: "value" } });
294
+ if (config.build.context) {
295
+ entries.push({ text: " context: " + config.build.context + "\n", properties: { type: "value" } });
296
+ }
297
+ if (config.build.target) {
298
+ entries.push({ text: " target: " + config.build.target + "\n", properties: { type: "value" } });
299
+ }
300
+ entries.push({ text: "\n", properties: { type: "blank" } });
301
+ } else if (config.dockerComposeFile) {
302
+ entries.push({ text: editor.t("panel.section_compose") + "\n", properties: { type: "heading" } });
303
+ const files = Array.isArray(config.dockerComposeFile)
304
+ ? config.dockerComposeFile.join(", ")
305
+ : config.dockerComposeFile;
306
+ entries.push({ text: " files: " + files + "\n", properties: { type: "value" } });
307
+ if (config.service) {
308
+ entries.push({ text: " service: " + config.service + "\n", properties: { type: "value" } });
309
+ }
310
+ entries.push({ text: "\n", properties: { type: "blank" } });
311
+ }
312
+
313
+ // Features
314
+ if (config.features && Object.keys(config.features).length > 0) {
315
+ entries.push({ text: editor.t("panel.section_features") + "\n", properties: { type: "heading" } });
316
+ for (const [id, opts] of Object.entries(config.features)) {
317
+ entries.push({ text: " + " + id + "\n", properties: { type: "feature", id } });
318
+ if (typeof opts === "object" && opts !== null) {
319
+ const optStr = Object.entries(opts as Record<string, unknown>)
320
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
321
+ .join(", ");
322
+ if (optStr) {
323
+ entries.push({ text: " " + optStr + "\n", properties: { type: "feature-opts" } });
324
+ }
325
+ }
326
+ }
327
+ entries.push({ text: "\n", properties: { type: "blank" } });
328
+ }
329
+
330
+ // Ports
331
+ if (config.forwardPorts && config.forwardPorts.length > 0) {
332
+ entries.push({ text: editor.t("panel.section_ports") + "\n", properties: { type: "heading" } });
333
+ for (const port of config.forwardPorts) {
334
+ const attrs = config.portsAttributes?.[String(port)];
335
+ const proto = attrs?.protocol ?? "tcp";
336
+ let detail = ` ${port} -> ${proto}`;
337
+ if (attrs?.label) detail += ` (${attrs.label})`;
338
+ if (attrs?.onAutoForward) detail += ` [${attrs.onAutoForward}]`;
339
+ entries.push({ text: detail + "\n", properties: { type: "port", port: String(port) } });
340
+ }
341
+ entries.push({ text: "\n", properties: { type: "blank" } });
342
+ }
343
+
344
+ // Environment
345
+ const allEnv: Record<string, string> = {};
346
+ if (config.containerEnv) Object.assign(allEnv, config.containerEnv);
347
+ if (config.remoteEnv) Object.assign(allEnv, config.remoteEnv);
348
+ const envKeys = Object.keys(allEnv);
349
+ if (envKeys.length > 0) {
350
+ entries.push({ text: editor.t("panel.section_env") + "\n", properties: { type: "heading" } });
351
+ for (const k of envKeys) {
352
+ entries.push({ text: ` ${k} = ${allEnv[k]}\n`, properties: { type: "env" } });
353
+ }
354
+ entries.push({ text: "\n", properties: { type: "blank" } });
355
+ }
356
+
357
+ // Mounts
358
+ if (config.mounts && config.mounts.length > 0) {
359
+ entries.push({ text: editor.t("panel.section_mounts") + "\n", properties: { type: "heading" } });
360
+ for (const mount of config.mounts) {
361
+ entries.push({ text: " " + formatMount(mount) + "\n", properties: { type: "mount" } });
362
+ }
363
+ entries.push({ text: "\n", properties: { type: "blank" } });
364
+ }
365
+
366
+ // Users
367
+ if (config.containerUser || config.remoteUser) {
368
+ entries.push({ text: editor.t("panel.section_users") + "\n", properties: { type: "heading" } });
369
+ if (config.containerUser) {
370
+ entries.push({ text: " containerUser: " + config.containerUser + "\n", properties: { type: "value" } });
371
+ }
372
+ if (config.remoteUser) {
373
+ entries.push({ text: " remoteUser: " + config.remoteUser + "\n", properties: { type: "value" } });
374
+ }
375
+ entries.push({ text: "\n", properties: { type: "blank" } });
376
+ }
377
+
378
+ // Lifecycle Commands
379
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
380
+ ["initializeCommand", config.initializeCommand],
381
+ ["onCreateCommand", config.onCreateCommand],
382
+ ["updateContentCommand", config.updateContentCommand],
383
+ ["postCreateCommand", config.postCreateCommand],
384
+ ["postStartCommand", config.postStartCommand],
385
+ ["postAttachCommand", config.postAttachCommand],
386
+ ];
387
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
388
+ if (defined.length > 0) {
389
+ entries.push({ text: editor.t("panel.section_lifecycle") + "\n", properties: { type: "heading" } });
390
+ for (const [cmdName, cmd] of defined) {
391
+ entries.push({
392
+ text: ` ${cmdName}: ${formatLifecycleCommand(cmd!)}\n`,
393
+ properties: { type: "lifecycle", command: cmdName },
394
+ });
395
+ }
396
+ entries.push({ text: "\n", properties: { type: "blank" } });
397
+ }
398
+
399
+ // Host Requirements
400
+ if (config.hostRequirements) {
401
+ const hr = config.hostRequirements;
402
+ entries.push({ text: editor.t("panel.section_host_req") + "\n", properties: { type: "heading" } });
403
+ if (hr.cpus) entries.push({ text: ` cpus: ${hr.cpus}\n`, properties: { type: "value" } });
404
+ if (hr.memory) entries.push({ text: ` memory: ${hr.memory}\n`, properties: { type: "value" } });
405
+ if (hr.storage) entries.push({ text: ` storage: ${hr.storage}\n`, properties: { type: "value" } });
406
+ if (hr.gpu) entries.push({ text: ` gpu: ${JSON.stringify(hr.gpu)}\n`, properties: { type: "value" } });
407
+ entries.push({ text: "\n", properties: { type: "blank" } });
408
+ }
409
+
410
+ // Separator before buttons
411
+ entries.push({
412
+ text: "─".repeat(40) + "\n",
413
+ properties: { type: "separator" },
414
+ });
415
+
416
+ // Action buttons row (Tab-navigable, like pkg.ts)
417
+ entries.push({ text: " ", properties: { type: "spacer" } });
418
+ for (let i = 0; i < infoButtons.length; i++) {
419
+ const btn = infoButtons[i];
420
+ const focused = infoFocus.index === i;
421
+ const leftBracket = focused ? "[" : " ";
422
+ const rightBracket = focused ? "]" : " ";
423
+ entries.push({
424
+ text: `${leftBracket} ${btn.label} ${rightBracket}`,
425
+ properties: { type: "button", focused, btnIndex: i },
426
+ });
427
+ if (i < infoButtons.length - 1) {
428
+ entries.push({ text: " ", properties: { type: "spacer" } });
429
+ }
430
+ }
431
+ entries.push({ text: "\n", properties: { type: "newline" } });
432
+
433
+ // Help line
434
+ entries.push({
435
+ text: editor.t("panel.footer") + "\n",
436
+ properties: { type: "footer" },
437
+ });
438
+
439
+ return entries;
440
+ }
441
+
442
+ function entriesToContent(entries: TextPropertyEntry[]): string {
443
+ return entries.map((e) => e.text).join("");
444
+ }
445
+
446
+ function applyInfoHighlighting(): void {
447
+ if (infoPanelBufferId === null) return;
448
+ const bufferId = infoPanelBufferId;
449
+
450
+ editor.clearNamespace(bufferId, "devcontainer");
451
+
452
+ const content = cachedContent;
453
+ if (!content) return;
454
+
455
+ const lines = content.split("\n");
456
+ let byteOffset = 0;
457
+
458
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
459
+ const line = lines[lineIdx];
460
+ const lineStart = byteOffset;
461
+ const lineByteLen = editor.utf8ByteLength(line);
462
+ const lineEnd = lineStart + lineByteLen;
463
+
464
+ // Heading lines (sections)
465
+ if (
466
+ line.startsWith("Dev Container:") ||
467
+ line === editor.t("panel.section_image") ||
468
+ line === editor.t("panel.section_build") ||
469
+ line === editor.t("panel.section_compose") ||
470
+ line === editor.t("panel.section_features") ||
471
+ line === editor.t("panel.section_ports") ||
472
+ line === editor.t("panel.section_env") ||
473
+ line === editor.t("panel.section_mounts") ||
474
+ line === editor.t("panel.section_users") ||
475
+ line === editor.t("panel.section_lifecycle") ||
476
+ line === editor.t("panel.section_host_req")
477
+ ) {
478
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
479
+ fg: colors.heading,
480
+ bold: true,
481
+ });
482
+ }
483
+ // Feature lines
484
+ else if (line.startsWith(" + ")) {
485
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
486
+ fg: colors.feature,
487
+ });
488
+ }
489
+ // Port lines
490
+ else if (line.match(/^\s+\d+\s*->/)) {
491
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
492
+ fg: colors.port,
493
+ });
494
+ }
495
+ // Key = value lines (env vars)
496
+ else if (line.match(/^\s+\w+\s*=/)) {
497
+ const eqIdx = line.indexOf("=");
498
+ if (eqIdx > 0) {
499
+ const keyEnd = lineStart + editor.utf8ByteLength(line.substring(0, eqIdx));
500
+ editor.addOverlay(bufferId, "devcontainer", lineStart, keyEnd, {
501
+ fg: colors.key,
502
+ });
503
+ }
504
+ }
505
+ // Separator
506
+ else if (line.match(/^─+$/)) {
507
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
508
+ fg: colors.footer,
509
+ });
510
+ }
511
+ // Footer help line
512
+ else if (line === editor.t("panel.footer")) {
513
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
514
+ fg: colors.footer,
515
+ italic: true,
516
+ });
517
+ }
518
+
519
+ byteOffset += lineByteLen + 1; // +1 for newline
520
+ }
521
+
522
+ // Apply button highlighting using entry-based scanning
523
+ // We need to walk entries to find button text positions in the content
524
+ applyButtonHighlighting();
525
+ }
526
+
527
+ function applyButtonHighlighting(): void {
528
+ if (infoPanelBufferId === null) return;
529
+ const bufferId = infoPanelBufferId;
530
+
531
+ // Re-scan entries to find button positions
532
+ const entries = buildInfoEntries();
533
+ let byteOffset = 0;
534
+
535
+ for (const entry of entries) {
536
+ const props = entry.properties as Record<string, unknown>;
537
+ const len = editor.utf8ByteLength(entry.text);
538
+
539
+ if (props.type === "button") {
540
+ const focused = props.focused as boolean;
541
+ if (focused) {
542
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
543
+ fg: colors.buttonFocused,
544
+ bg: colors.buttonFocusedBg,
545
+ bold: true,
546
+ });
547
+ } else {
548
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
549
+ fg: colors.button,
550
+ });
551
+ }
552
+ }
553
+
554
+ byteOffset += len;
555
+ }
556
+ }
557
+
558
+ function updateInfoPanel(): void {
559
+ if (infoPanelBufferId === null) return;
560
+ const entries = buildInfoEntries();
561
+ cachedContent = entriesToContent(entries);
562
+ editor.setVirtualBufferContent(infoPanelBufferId, entries);
563
+ applyInfoHighlighting();
564
+ }
565
+
566
+ // =============================================================================
567
+ // Mode Definition
568
+ // =============================================================================
569
+
570
+ editor.defineMode(
571
+ "devcontainer-info",
572
+ [
573
+ ["Tab", "devcontainer_next_button"],
574
+ ["S-Tab", "devcontainer_prev_button"],
575
+ ["Return", "devcontainer_activate_button"],
576
+ ["M-r", "devcontainer_run_lifecycle"],
577
+ ["M-o", "devcontainer_open_config"],
578
+ ["M-b", "devcontainer_rebuild"],
579
+ ["q", "devcontainer_close_info"],
580
+ ["Escape", "devcontainer_close_info"],
581
+ ],
582
+ true, // read-only
583
+ false, // allow_text_input
584
+ true, // inherit Normal-context bindings so arrow keys / page nav still work
585
+ );
586
+
587
+ // =============================================================================
588
+ // Info Panel Button Navigation
589
+ // =============================================================================
590
+
591
+ // Plugin code runs inside an IIFE, so `function foo() {}` declarations don't
592
+ // land on globalThis on their own. Register each handler explicitly so it can
593
+ // be referenced by string name from defineMode bindings, registerCommand, and
594
+ // event handlers (see also pkg.ts).
595
+
596
+ function devcontainer_next_button(): void {
597
+ if (!infoPanelOpen) return;
598
+ infoFocus = { type: "button", index: (infoFocus.index + 1) % infoButtons.length };
599
+ updateInfoPanel();
600
+ }
601
+ registerHandler("devcontainer_next_button", devcontainer_next_button);
602
+
603
+ function devcontainer_prev_button(): void {
604
+ if (!infoPanelOpen) return;
605
+ infoFocus = { type: "button", index: (infoFocus.index - 1 + infoButtons.length) % infoButtons.length };
606
+ updateInfoPanel();
607
+ }
608
+ registerHandler("devcontainer_prev_button", devcontainer_prev_button);
609
+
610
+ function devcontainer_activate_button(): void {
611
+ if (!infoPanelOpen) return;
612
+ const btn = infoButtons[infoFocus.index];
613
+ if (!btn) return;
614
+ const handler = (globalThis as Record<string, unknown>)[btn.command];
615
+ if (typeof handler === "function") {
616
+ (handler as () => void)();
617
+ }
618
+ }
619
+ registerHandler("devcontainer_activate_button", devcontainer_activate_button);
620
+
621
+ // =============================================================================
622
+ // Commands
623
+ // =============================================================================
624
+
625
+ async function devcontainer_show_info(): Promise<void> {
626
+ if (!config) {
627
+ editor.setStatus(editor.t("status.no_config"));
628
+ return;
629
+ }
630
+
631
+ // Re-routed through the shared panel slot (Bug #6 retest):
632
+ // dropping the previous flag-based dedupe because it kept the
633
+ // info-panel buffer "open" in module state even when the user
634
+ // had already closed it with `q`, leaving the next invocation
635
+ // either refreshing a dead buffer or stacking a new split.
636
+ // The slot helper handles existence-checking against the live
637
+ // split list each call.
638
+ infoFocus = { type: "button", index: 0 };
639
+ const entries = buildInfoEntries();
640
+ cachedContent = entriesToContent(entries);
641
+
642
+ const result = await openVirtualInPanelSlot({
643
+ name: "*Dev Container*",
644
+ mode: "devcontainer-info",
645
+ entries,
646
+ });
647
+
648
+ if (result !== null) {
649
+ infoPanelOpen = true;
650
+ infoPanelBufferId = result.bufferId;
651
+ infoPanelSplitId = result.splitId;
652
+ applyInfoHighlighting();
653
+ editor.setStatus(editor.t("status.panel_opened"));
654
+ }
655
+ }
656
+ registerHandler("devcontainer_show_info", devcontainer_show_info);
657
+
658
+ function devcontainer_close_info(): void {
659
+ if (!infoPanelOpen) return;
660
+
661
+ if (infoPanelSplitId !== null) {
662
+ editor.closeSplit(infoPanelSplitId);
663
+ }
664
+ if (infoPanelBufferId !== null) {
665
+ editor.closeBuffer(infoPanelBufferId);
666
+ }
667
+
668
+ infoPanelOpen = false;
669
+ infoPanelBufferId = null;
670
+ infoPanelSplitId = null;
671
+ editor.setStatus(editor.t("status.panel_closed"));
672
+ }
673
+ registerHandler("devcontainer_close_info", devcontainer_close_info);
674
+
675
+ function devcontainer_open_config(): void {
676
+ if (configPath) {
677
+ editor.openFile(configPath, null, null);
678
+ } else {
679
+ editor.setStatus(editor.t("status.no_config"));
680
+ }
681
+ }
682
+ registerHandler("devcontainer_open_config", devcontainer_open_config);
683
+
684
+ function devcontainer_run_lifecycle(): void {
685
+ if (!config) {
686
+ editor.setStatus(editor.t("status.no_config"));
687
+ return;
688
+ }
689
+
690
+ // `initializeCommand` is the host-side prologue per the dev-container
691
+ // spec — surface it in the picker so users can re-run it on demand.
692
+ // The automatic attach flow runs it separately (see runDevcontainerUp)
693
+ // before `devcontainer up`, so the CLI-driven hooks that follow don't
694
+ // re-run it.
695
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
696
+ ["initializeCommand", config.initializeCommand],
697
+ ["onCreateCommand", config.onCreateCommand],
698
+ ["updateContentCommand", config.updateContentCommand],
699
+ ["postCreateCommand", config.postCreateCommand],
700
+ ["postStartCommand", config.postStartCommand],
701
+ ["postAttachCommand", config.postAttachCommand],
702
+ ];
703
+
704
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
705
+ if (defined.length === 0) {
706
+ editor.setStatus(editor.t("status.no_lifecycle"));
707
+ return;
708
+ }
709
+
710
+ const suggestions: PromptSuggestion[] = defined.map(([name, cmd]) => ({
711
+ text: name,
712
+ description: formatLifecycleCommand(cmd!),
713
+ value: name,
714
+ }));
715
+
716
+ editor.startPrompt(editor.t("prompt.run_lifecycle"), "devcontainer-lifecycle");
717
+ editor.setPromptSuggestions(suggestions);
718
+ }
719
+ registerHandler("devcontainer_run_lifecycle", devcontainer_run_lifecycle);
720
+
721
+
722
+
723
+ /// Critical bug from interactive walkthrough: lifecycle command
724
+ /// stdout/stderr were captured in `result.stdout` / `result.stderr`
725
+ /// and then discarded — the user only saw a status line like
726
+ /// `postCreateCommand failed (exit 1)` with zero way to see the
727
+ /// real error. Now we surface the full output via the shared
728
+ /// panel slot.
729
+ ///
730
+ /// Always render (even on success) so users can see what the
731
+ /// command actually did. Status line keeps the at-a-glance
732
+ /// summary; the panel carries the detail.
733
+ async function surfaceLifecycleResult(
734
+ cmdName: string,
735
+ label: string | null,
736
+ cmdline: string,
737
+ result: { stdout: string; stderr: string; exit_code: number },
738
+ ): Promise<void> {
739
+ // Status line: the at-a-glance signal. Detail lands in the
740
+ // panel slot below.
741
+ if (result.exit_code === 0) {
742
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
743
+ } else if (label !== null) {
744
+ editor.setStatus(
745
+ editor.t("status.failed_sub", {
746
+ name: cmdName,
747
+ label,
748
+ code: String(result.exit_code),
749
+ }),
750
+ );
751
+ } else {
752
+ editor.setStatus(
753
+ editor.t("status.failed", {
754
+ name: cmdName,
755
+ code: String(result.exit_code),
756
+ }),
757
+ );
758
+ }
759
+
760
+ const headerLine = label !== null
761
+ ? `--- ${cmdName} (${label}) — exit ${result.exit_code} ---\n`
762
+ : `--- ${cmdName} — exit ${result.exit_code} ---\n`;
763
+ const cmdLineText = `$ ${cmdline}\n`;
764
+ const stdoutBlock = result.stdout.length > 0 ? result.stdout : "";
765
+ const stderrBlock = result.stderr.length > 0
766
+ ? (result.stdout.length > 0 ? "\n--- stderr ---\n" : "") + result.stderr
767
+ : "";
768
+ const body = stdoutBlock + stderrBlock;
769
+ const text = headerLine + cmdLineText
770
+ + (body.length > 0 ? body : "(no output)\n");
771
+
772
+ await openVirtualInPanelSlot({
773
+ name: "*Dev Container Lifecycle*",
774
+ mode: "devcontainer-info",
775
+ entries: [{ text, properties: { type: "log" } }],
776
+ });
777
+ }
778
+
779
+ /// Per-workspace storage for `remoteWorkspaceFolder` captured at
780
+ /// attach time. The plugin module re-loads after `setAuthority`'s
781
+ /// restart, losing in-memory state, so we persist via plugin
782
+ /// global state. Read back via `lifecycleCwd()` when running
783
+ /// lifecycle commands.
784
+ function remoteWorkspaceKey(): string {
785
+ return "remote-workspace:" + editor.getCwd();
786
+ }
787
+
788
+ function writeRemoteWorkspace(value: string | null): void {
789
+ editor.setGlobalState(remoteWorkspaceKey(), value);
790
+ }
791
+
792
+ function readRemoteWorkspace(): string | null {
793
+ const raw = editor.getGlobalState(remoteWorkspaceKey()) as unknown;
794
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
795
+ }
796
+
797
+ /// Pick the cwd to pass to lifecycle-command `spawnProcess` calls.
798
+ /// When attached to a Container authority, returns the recorded
799
+ /// `remoteWorkspaceFolder` so `docker exec -w` lands inside the
800
+ /// container. Otherwise returns undefined so the runtime fills
801
+ /// in the editor's host working_dir (the local-authority path).
802
+ function lifecycleCwd(): string | undefined {
803
+ if (editor.getAuthorityLabel().startsWith("Container:")) {
804
+ return readRemoteWorkspace() ?? undefined;
805
+ }
806
+ return undefined;
807
+ }
808
+
809
+ /// Per-workspace cache of the `userEnvProbe` result. Spec says
810
+ /// the tool runs the probe shell once at attach and applies the
811
+ /// captured env to every subsequent remote process. We persist
812
+ /// across the post-attach restart via plugin global state so the
813
+ /// reloaded plugin instance reuses the same snapshot.
814
+ function userEnvProbeKey(): string {
815
+ return "user-env-probe:" + editor.getCwd();
816
+ }
817
+
818
+ function readCachedProbedEnv(): Record<string, string> | null {
819
+ const raw = editor.getGlobalState(userEnvProbeKey()) as unknown;
820
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
821
+ const out: Record<string, string> = {};
822
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
823
+ if (typeof v === "string") out[k] = v;
824
+ }
825
+ return out;
826
+ }
827
+ return null;
828
+ }
829
+
830
+ function writeCachedProbedEnv(env: Record<string, string>): void {
831
+ editor.setGlobalState(userEnvProbeKey(), env as unknown);
832
+ }
833
+
834
+ /// Run the `userEnvProbe` shell (per spec) and capture its env.
835
+ /// Caches the result so subsequent calls are free. Returns `{}`
836
+ /// when probe is unset / "none" / failed.
837
+ async function getOrComputeProbedEnv(): Promise<Record<string, string>> {
838
+ const cached = readCachedProbedEnv();
839
+ if (cached !== null) return cached;
840
+
841
+ const probe = config?.userEnvProbe;
842
+ if (!probe || probe === "none") {
843
+ writeCachedProbedEnv({});
844
+ return {};
845
+ }
846
+
847
+ // Map enum → bash flags. `loginShell` = `bash -lc`,
848
+ // `interactiveShell` = `bash -ic`, etc. The probe runs `env`
849
+ // and we parse stdout into KEY=VALUE pairs.
850
+ const flagMap: Record<string, string[]> = {
851
+ loginShell: ["-l"],
852
+ loginInteractiveShell: ["-l", "-i"],
853
+ interactiveShell: ["-i"],
854
+ };
855
+ const flags = flagMap[probe] ?? [];
856
+ const cwd = lifecycleCwd() ?? "";
857
+ // The probe shell needs `remoteEnv` applied too so users can put
858
+ // BASH_ENV / ENV / NODE_OPTIONS / etc. there and have the probe
859
+ // pick them up. Without this, bash's non-interactive-login
860
+ // semantics (`BASH_ENV` sourcing) wouldn't see the user's
861
+ // configured rc file.
862
+ const baseEnv: Record<string, string> = config?.remoteEnv ?? {};
863
+ const [bin, probeArgs] = wrapWithEnv(baseEnv, "bash", [...flags, "-c", "env"]);
864
+ const result = await editor.spawnProcess(bin, probeArgs, cwd);
865
+ if (result.exit_code !== 0) {
866
+ editor.debug(
867
+ `devcontainer: userEnvProbe (${probe}) failed: ${result.stderr.trim()}`,
868
+ );
869
+ writeCachedProbedEnv({});
870
+ return {};
871
+ }
872
+ const env: Record<string, string> = {};
873
+ for (const line of result.stdout.split("\n")) {
874
+ const eq = line.indexOf("=");
875
+ if (eq > 0) {
876
+ env[line.slice(0, eq)] = line.slice(eq + 1);
877
+ }
878
+ }
879
+ writeCachedProbedEnv(env);
880
+ return env;
881
+ }
882
+
883
+ /// Build the merged env passed to lifecycle commands per spec:
884
+ /// userEnvProbe-captured ∪ remoteEnv (remoteEnv overrides probe).
885
+ /// Skipped when not attached to a Container — remoteEnv is a
886
+ /// container-side concept, the local case relies on whatever env
887
+ /// the editor itself has.
888
+ async function effectiveLifecycleEnv(): Promise<Record<string, string>> {
889
+ if (!editor.getAuthorityLabel().startsWith("Container:")) return {};
890
+ const probed = await getOrComputeProbedEnv();
891
+ const out: Record<string, string> = { ...probed };
892
+ if (config?.remoteEnv) {
893
+ for (const [k, v] of Object.entries(config.remoteEnv)) {
894
+ out[k] = v;
895
+ }
896
+ }
897
+ return out;
898
+ }
899
+
900
+ /// Probe the just-launched container's user-shell env so the docker
901
+ /// authority's spawner can apply it to every `docker exec` (including
902
+ /// the LSP `command_exists` probe and the LSP server spawn itself).
903
+ ///
904
+ /// Why pre-restart, not post-restart: `setAuthority` rebuilds the
905
+ /// editor in place; the next plugin instance can't influence the
906
+ /// already-installed authority's spawner without a second restart.
907
+ /// We have one shot, here, while we still hold the host-side spawner
908
+ /// and know which container we're talking to.
909
+ ///
910
+ /// Why bash login-interactive: per the dev-container spec, the
911
+ /// default `userEnvProbe` is `loginInteractiveShell`. `bash -lic env`
912
+ /// matches what an attached terminal would see — including PATH
913
+ /// additions from `~/.profile`, `~/.bashrc`, and friends. Custom
914
+ /// `userEnvProbe` settings (`loginShell`, `interactiveShell`, `none`)
915
+ /// are honoured.
916
+ ///
917
+ /// Failures (no bash, probe times out, exit non-zero) degrade
918
+ /// gracefully: we return `[]` and the user gets the bare
919
+ /// container-default PATH back — same behaviour as before this fix,
920
+ /// no regression.
921
+ async function captureContainerLoginEnv(
922
+ result: DevcontainerUpResult,
923
+ ): Promise<Array<[string, string]>> {
924
+ if (!result.containerId) return [];
925
+ const probe = config?.userEnvProbe ?? "loginInteractiveShell";
926
+ if (probe === "none") return [];
927
+
928
+ const flagMap: Record<string, string[]> = {
929
+ loginShell: ["-l"],
930
+ loginInteractiveShell: ["-l", "-i"],
931
+ interactiveShell: ["-i"],
932
+ };
933
+ const flags = flagMap[probe];
934
+ if (!flags) return [];
935
+
936
+ // Compose `docker exec -i [-u USER] [-w WORKSPACE] <id> bash ...`
937
+ // by hand: at this point the authority hasn't been installed yet,
938
+ // so `editor.spawnProcess` would route to the host. Use
939
+ // `spawnHostProcess` and address the container directly via the
940
+ // host docker CLI.
941
+ const dockerArgs: string[] = ["exec", "-i"];
942
+ if (result.remoteUser) {
943
+ dockerArgs.push("-u", result.remoteUser);
944
+ }
945
+ if (result.remoteWorkspaceFolder) {
946
+ dockerArgs.push("-w", result.remoteWorkspaceFolder);
947
+ }
948
+ dockerArgs.push(result.containerId, "bash", ...flags, "-c", "env");
949
+
950
+ const probeResult = await editor.spawnHostProcess("docker", dockerArgs);
951
+ if (probeResult.exit_code !== 0) {
952
+ editor.debug(
953
+ `devcontainer: container userEnvProbe (${probe}) failed exit=${probeResult.exit_code}: ${probeResult.stderr.trim()}`,
954
+ );
955
+ return [];
956
+ }
957
+
958
+ // Filter to a small allowlist of high-value entries. Forwarding the
959
+ // entire `env` dump risks shadowing useful container defaults
960
+ // (`HOSTNAME`, `_`, …) and balloons the `docker exec` arg list.
961
+ // `PATH` is the one that drives the LSP fix; the others are common
962
+ // setup the user's shell exports and a non-shell exec wouldn't.
963
+ const wanted = new Set(["PATH", "HOME", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME"]);
964
+ const out: Array<[string, string]> = [];
965
+ for (const line of probeResult.stdout.split("\n")) {
966
+ const eq = line.indexOf("=");
967
+ if (eq <= 0) continue;
968
+ const key = line.slice(0, eq);
969
+ if (!wanted.has(key)) continue;
970
+ out.push([key, line.slice(eq + 1)]);
971
+ }
972
+ return out;
973
+ }
974
+
975
+ /// Wrap `[bin, args]` with an `env K1=V1 K2=V2 bin args...`
976
+ /// invocation when `env` is non-empty. Returns the original pair
977
+ /// when env is empty (no wrapper needed).
978
+ ///
979
+ /// Note: GNU `env` doesn't recognize `--` as an options
980
+ /// terminator (it dies with `env: '--': No such file or directory`).
981
+ /// `env` parses K=V pairs greedily until it hits a non-K=V word,
982
+ /// which it treats as the command. As long as `bin` doesn't
983
+ /// contain `=`, this is unambiguous.
984
+ function wrapWithEnv(
985
+ env: Record<string, string>,
986
+ bin: string,
987
+ args: string[],
988
+ ): [string, string[]] {
989
+ const keys = Object.keys(env);
990
+ if (keys.length === 0) return [bin, args];
991
+ const envArgs = keys.map((k) => `${k}=${env[k]}`);
992
+ return ["env", [...envArgs, bin, ...args]];
993
+ }
994
+
995
+ /// Spec: object-form lifecycle commands run their entries in
996
+ /// parallel; the stage waits for all to complete; the stage
997
+ /// succeeds iff every entry exited 0. Implementation:
998
+ /// `Promise.all` over an array of per-entry promises, each
999
+ /// reporting its exit code. We aggregate failures into a single
1000
+ /// status message at the end.
1001
+ async function runLifecycleObjectForm(
1002
+ cmdName: string,
1003
+ cmd: Record<string, string | string[]>,
1004
+ ): Promise<void> {
1005
+ const entries = Object.entries(cmd);
1006
+ if (entries.length === 0) {
1007
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
1008
+ return;
1009
+ }
1010
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
1011
+
1012
+ const cwd = lifecycleCwd() ?? "";
1013
+ const env = await effectiveLifecycleEnv();
1014
+ const results = await Promise.all(
1015
+ entries.map(async ([label, subcmd]) => {
1016
+ let origBin: string;
1017
+ let origArgs: string[];
1018
+ let cmdline: string;
1019
+ if (Array.isArray(subcmd)) {
1020
+ [origBin, ...origArgs] = subcmd;
1021
+ cmdline = [origBin, ...origArgs].join(" ");
1022
+ } else {
1023
+ origBin = "sh";
1024
+ origArgs = ["-c", subcmd as string];
1025
+ cmdline = subcmd as string;
1026
+ }
1027
+ const [bin, args] = wrapWithEnv(env, origBin, origArgs);
1028
+ const r = await editor.spawnProcess(bin, args, cwd);
1029
+ return { label, cmdline, result: r };
1030
+ }),
1031
+ );
1032
+
1033
+ // Render every entry's output into the panel slot in one
1034
+ // batched message — N separate calls would flicker the panel
1035
+ // across N intermediate states. Failed entries first so the
1036
+ // user sees the failures even if stdout is enormous.
1037
+ const sorted = [...results].sort(
1038
+ (a, b) => Number(a.result.exit_code === 0) - Number(b.result.exit_code === 0),
1039
+ );
1040
+ const sections = sorted.map(({ label, cmdline, result: r }) => {
1041
+ const header = `--- ${cmdName} (${label}) — exit ${r.exit_code} ---\n`;
1042
+ const cmdLine = `$ ${cmdline}\n`;
1043
+ const stdoutBlock = r.stdout.length > 0 ? r.stdout : "";
1044
+ const stderrBlock = r.stderr.length > 0
1045
+ ? (r.stdout.length > 0 ? "\n--- stderr ---\n" : "") + r.stderr
1046
+ : "";
1047
+ const body = stdoutBlock + stderrBlock;
1048
+ return header + cmdLine + (body.length > 0 ? body : "(no output)\n");
1049
+ });
1050
+ await openVirtualInPanelSlot({
1051
+ name: "*Dev Container Lifecycle*",
1052
+ mode: "devcontainer-info",
1053
+ entries: [{ text: sections.join("\n"), properties: { type: "log" } }],
1054
+ });
1055
+
1056
+ const failed = results.filter((r) => r.result.exit_code !== 0);
1057
+ if (failed.length === 0) {
1058
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
1059
+ return;
1060
+ }
1061
+ // Surface the first failure in the status message — same key
1062
+ // the old sequential path used so existing translations keep
1063
+ // working.
1064
+ const first = failed[0];
1065
+ editor.setStatus(
1066
+ editor.t("status.failed_sub", {
1067
+ name: cmdName,
1068
+ label: first.label,
1069
+ code: String(first.result.exit_code),
1070
+ }),
1071
+ );
1072
+ }
1073
+
1074
+ function devcontainer_show_features(): void {
1075
+ if (!config || !config.features || Object.keys(config.features).length === 0) {
1076
+ editor.setStatus(editor.t("status.no_features"));
1077
+ return;
1078
+ }
1079
+
1080
+ const suggestions: PromptSuggestion[] = Object.entries(config.features).map(([id, opts]) => {
1081
+ let desc = "";
1082
+ if (typeof opts === "object" && opts !== null) {
1083
+ desc = Object.entries(opts as Record<string, unknown>)
1084
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
1085
+ .join(", ");
1086
+ } else if (typeof opts === "string") {
1087
+ desc = opts;
1088
+ }
1089
+ return { text: id, description: desc || "(default options)" };
1090
+ });
1091
+
1092
+ editor.startPrompt(editor.t("prompt.features"), "devcontainer-features");
1093
+ editor.setPromptSuggestions(suggestions);
1094
+ }
1095
+ registerHandler("devcontainer_show_features", devcontainer_show_features);
1096
+
1097
+ /// Parse `docker port <id>` output into a map from
1098
+ /// "<container-port>/<proto>" to "<host>:<host-port>".
1099
+ ///
1100
+ /// Each output line looks like `8080/tcp -> 0.0.0.0:49153`. Malformed
1101
+ /// lines are skipped — we prefer a partial merge over bailing on
1102
+ /// unknown formats from future Docker versions.
1103
+ function parseDockerPortOutput(stdout: string): Record<string, string> {
1104
+ const map: Record<string, string> = {};
1105
+ for (const rawLine of stdout.split("\n")) {
1106
+ const line = rawLine.trim();
1107
+ if (!line) continue;
1108
+ const arrow = line.indexOf(" -> ");
1109
+ if (arrow < 0) continue;
1110
+ const left = line.slice(0, arrow).trim();
1111
+ const right = line.slice(arrow + 4).trim();
1112
+ if (left && right) map[left] = right;
1113
+ }
1114
+ return map;
1115
+ }
1116
+
1117
+ async function devcontainer_show_ports(): Promise<void> {
1118
+ if (!config || !config.forwardPorts || config.forwardPorts.length === 0) {
1119
+ editor.setStatus(editor.t("status.no_ports"));
1120
+ return;
1121
+ }
1122
+
1123
+ // When attached to a container, merge runtime bindings from
1124
+ // `docker port <id>` into the prompt descriptions so the user sees
1125
+ // which configured ports actually reached the host. Off-container
1126
+ // the runtime side is unavailable; fall back to config-only.
1127
+ let runtime: Record<string, string> = {};
1128
+ const authorityLabel = editor.getAuthorityLabel();
1129
+ const prefix = "Container:";
1130
+ if (authorityLabel.startsWith(prefix)) {
1131
+ const containerId = authorityLabel.slice(prefix.length);
1132
+ if (containerId.length > 0) {
1133
+ const which = await editor.spawnHostProcess("which", ["docker"]);
1134
+ if (which.exit_code === 0) {
1135
+ const res = await editor.spawnHostProcess(
1136
+ "docker",
1137
+ ["port", containerId],
1138
+ editor.getCwd(),
1139
+ );
1140
+ if (res.exit_code === 0) {
1141
+ runtime = parseDockerPortOutput(res.stdout);
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const suggestions: PromptSuggestion[] = config.forwardPorts.map((port) => {
1148
+ const attrs = config!.portsAttributes?.[String(port)];
1149
+ const proto = attrs?.protocol ?? "tcp";
1150
+ let desc = proto;
1151
+ if (attrs?.label) desc += ` · ${attrs.label}`;
1152
+ if (attrs?.onAutoForward) desc += ` (${attrs.onAutoForward})`;
1153
+ // Runtime bindings are keyed by "<port>/<protocol>" — Docker
1154
+ // emits `tcp` / `udp` lowercased. Match protocol defensively.
1155
+ const key = `${port}/${proto.toLowerCase()}`;
1156
+ const binding = runtime[key];
1157
+ if (binding) {
1158
+ desc += ` → ${binding}`;
1159
+ }
1160
+ return { text: String(port), description: desc };
1161
+ });
1162
+
1163
+ // Surface runtime-only ports (exposed by the container but not
1164
+ // listed in forwardPorts) so users see the full picture.
1165
+ for (const [key, binding] of Object.entries(runtime)) {
1166
+ const slash = key.indexOf("/");
1167
+ const portStr = slash >= 0 ? key.slice(0, slash) : key;
1168
+ const portNum = Number(portStr);
1169
+ const alreadyListed =
1170
+ config.forwardPorts.some((p) => String(p) === portStr) ||
1171
+ (!Number.isNaN(portNum) && config.forwardPorts.some((p) => p === portNum));
1172
+ if (alreadyListed) continue;
1173
+ suggestions.push({
1174
+ text: portStr,
1175
+ description: `${key} · runtime only → ${binding}`,
1176
+ });
1177
+ }
1178
+
1179
+ editor.startPrompt(editor.t("prompt.ports"), "devcontainer-ports");
1180
+ editor.setPromptSuggestions(suggestions);
1181
+ }
1182
+ registerHandler("devcontainer_show_ports", devcontainer_show_ports);
1183
+
1184
+ // =============================================================================
1185
+ // Forwarded Ports Panel (spec §7)
1186
+ // =============================================================================
1187
+ //
1188
+ // Phase A's `devcontainer_show_ports` is a prompt-picker: quick
1189
+ // lookups for "did this port actually bind?" E-3 extends that with a
1190
+ // standalone panel so users can see configured + runtime-bound ports
1191
+ // at a glance rather than scrolling a picker.
1192
+ //
1193
+ // Data sources (identical to the picker):
1194
+ // - `config.forwardPorts` — declared port forwards
1195
+ // - `config.portsAttributes` — optional label / protocol / policy
1196
+ // - `docker port <id>` — runtime host binding per (port, proto)
1197
+ //
1198
+ // Layout: four columns — Configured | Protocol | Label | Runtime binding —
1199
+ // followed by any runtime-only ports (container exposed but not in
1200
+ // `forwardPorts`). Refresh key `r` re-runs `docker port` and rebuilds
1201
+ // the buffer. Close via `q` / Escape.
1202
+
1203
+ let portsPanelBufferId: number | null = null;
1204
+ let portsPanelSplitId: number | null = null;
1205
+ let portsPanelOpen = false;
1206
+
1207
+ type PortRow = {
1208
+ port: string;
1209
+ protocol: string;
1210
+ label: string;
1211
+ binding: string;
1212
+ source: "configured" | "runtime";
1213
+ };
1214
+
1215
+ async function gatherForwardedPortRows(): Promise<PortRow[]> {
1216
+ let runtime: Record<string, string> = {};
1217
+ const authorityLabel = editor.getAuthorityLabel();
1218
+ const prefix = "Container:";
1219
+ if (authorityLabel.startsWith(prefix)) {
1220
+ const containerId = authorityLabel.slice(prefix.length);
1221
+ if (containerId.length > 0) {
1222
+ const which = await editor.spawnHostProcess("which", ["docker"]);
1223
+ if (which.exit_code === 0) {
1224
+ const res = await editor.spawnHostProcess(
1225
+ "docker",
1226
+ ["port", containerId],
1227
+ editor.getCwd(),
1228
+ );
1229
+ if (res.exit_code === 0) {
1230
+ runtime = parseDockerPortOutput(res.stdout);
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ const rows: PortRow[] = [];
1237
+ const configured = config?.forwardPorts ?? [];
1238
+ for (const port of configured) {
1239
+ const attrs = config?.portsAttributes?.[String(port)];
1240
+ const protocol = attrs?.protocol ?? "tcp";
1241
+ const key = `${port}/${protocol.toLowerCase()}`;
1242
+ const binding = runtime[key] ?? "";
1243
+ const labelParts: string[] = [];
1244
+ if (attrs?.label) labelParts.push(attrs.label);
1245
+ if (attrs?.onAutoForward) labelParts.push(`(${attrs.onAutoForward})`);
1246
+ rows.push({
1247
+ port: String(port),
1248
+ protocol,
1249
+ label: labelParts.join(" "),
1250
+ binding,
1251
+ source: "configured",
1252
+ });
1253
+ }
1254
+
1255
+ // Runtime-only ports: the container exposed them but they aren't in
1256
+ // `forwardPorts`. Worth surfacing so users see the full picture.
1257
+ for (const [key, binding] of Object.entries(runtime)) {
1258
+ const slash = key.indexOf("/");
1259
+ const portStr = slash >= 0 ? key.slice(0, slash) : key;
1260
+ const proto = slash >= 0 ? key.slice(slash + 1) : "tcp";
1261
+ const portNum = Number(portStr);
1262
+ const alreadyListed =
1263
+ configured.some((p) => String(p) === portStr) ||
1264
+ (!Number.isNaN(portNum) && configured.some((p) => p === portNum));
1265
+ if (alreadyListed) continue;
1266
+ rows.push({
1267
+ port: portStr,
1268
+ protocol: proto,
1269
+ label: "",
1270
+ binding,
1271
+ source: "runtime",
1272
+ });
1273
+ }
1274
+ return rows;
1275
+ }
1276
+
1277
+ function buildPortsPanelEntries(rows: PortRow[]): TextPropertyEntry[] {
1278
+ const entries: TextPropertyEntry[] = [];
1279
+
1280
+ entries.push({
1281
+ text: editor.t("ports_panel.header") + "\n",
1282
+ properties: { type: "heading" },
1283
+ });
1284
+ entries.push({ text: "\n", properties: { type: "blank" } });
1285
+
1286
+ if (rows.length === 0) {
1287
+ entries.push({
1288
+ text: " " + editor.t("ports_panel.no_ports") + "\n",
1289
+ properties: { type: "value" },
1290
+ });
1291
+ entries.push({ text: "\n", properties: { type: "blank" } });
1292
+ } else {
1293
+ // Column widths — pick the larger of the header width or the
1294
+ // longest value so the header stays aligned even when all rows
1295
+ // are shorter than the label.
1296
+ const headers = {
1297
+ port: editor.t("ports_panel.col_configured"),
1298
+ protocol: editor.t("ports_panel.col_protocol"),
1299
+ label: editor.t("ports_panel.col_label"),
1300
+ binding: editor.t("ports_panel.col_binding"),
1301
+ };
1302
+ const width = (label: string, values: string[]): number =>
1303
+ Math.max(label.length, ...values.map((v) => v.length));
1304
+ const portW = width(
1305
+ headers.port,
1306
+ rows.map((r) => r.port),
1307
+ );
1308
+ const protoW = width(
1309
+ headers.protocol,
1310
+ rows.map((r) => r.protocol),
1311
+ );
1312
+ const labelW = width(
1313
+ headers.label,
1314
+ rows.map((r) => r.label),
1315
+ );
1316
+ const bindingW = width(
1317
+ headers.binding,
1318
+ rows.map((r) => r.binding),
1319
+ );
1320
+ const pad = (s: string, n: number): string =>
1321
+ s + " ".repeat(Math.max(0, n - s.length));
1322
+
1323
+ const headerLine =
1324
+ " " +
1325
+ pad(headers.port, portW) +
1326
+ " " +
1327
+ pad(headers.protocol, protoW) +
1328
+ " " +
1329
+ pad(headers.label, labelW) +
1330
+ " " +
1331
+ pad(headers.binding, bindingW);
1332
+ entries.push({
1333
+ text: headerLine + "\n",
1334
+ properties: { type: "heading" },
1335
+ });
1336
+ const rule =
1337
+ " " +
1338
+ "─".repeat(portW) +
1339
+ " " +
1340
+ "─".repeat(protoW) +
1341
+ " " +
1342
+ "─".repeat(labelW) +
1343
+ " " +
1344
+ "─".repeat(bindingW);
1345
+ entries.push({
1346
+ text: rule + "\n",
1347
+ properties: { type: "separator" },
1348
+ });
1349
+
1350
+ for (const row of rows) {
1351
+ const rendered =
1352
+ " " +
1353
+ pad(row.port, portW) +
1354
+ " " +
1355
+ pad(row.protocol, protoW) +
1356
+ " " +
1357
+ pad(row.label, labelW) +
1358
+ " " +
1359
+ pad(row.binding || "—", bindingW);
1360
+ entries.push({
1361
+ text: rendered + "\n",
1362
+ properties: { type: "port-row", source: row.source },
1363
+ });
1364
+ }
1365
+ entries.push({ text: "\n", properties: { type: "blank" } });
1366
+ }
1367
+
1368
+ entries.push({
1369
+ text: editor.t("ports_panel.footer") + "\n",
1370
+ properties: { type: "footer" },
1371
+ });
1372
+
1373
+ return entries;
1374
+ }
1375
+
1376
+ async function renderPortsPanel(): Promise<void> {
1377
+ if (portsPanelBufferId === null) return;
1378
+ const rows = await gatherForwardedPortRows();
1379
+ const entries = buildPortsPanelEntries(rows);
1380
+ editor.setVirtualBufferContent(portsPanelBufferId, entries);
1381
+ }
1382
+
1383
+ async function devcontainer_show_forwarded_ports_panel(): Promise<void> {
1384
+ if (!config) {
1385
+ editor.setStatus(editor.t("status.no_config"));
1386
+ return;
1387
+ }
1388
+
1389
+ // Bug #6 retest: route through the shared panel slot rather
1390
+ // than `createVirtualBufferInSplit` (which always splits) and
1391
+ // drop the flag-based dedupe (which left state stale when the
1392
+ // user closed the panel manually with `q`).
1393
+ const rows = await gatherForwardedPortRows();
1394
+ const entries = buildPortsPanelEntries(rows);
1395
+ const result = await openVirtualInPanelSlot({
1396
+ name: "*Dev Container Ports*",
1397
+ mode: "devcontainer-ports",
1398
+ entries,
1399
+ });
1400
+ if (result !== null) {
1401
+ portsPanelOpen = true;
1402
+ portsPanelBufferId = result.bufferId;
1403
+ portsPanelSplitId = result.splitId;
1404
+ editor.setStatus(editor.t("status.ports_panel_opened"));
1405
+ }
1406
+ }
1407
+ registerHandler(
1408
+ "devcontainer_show_forwarded_ports_panel",
1409
+ devcontainer_show_forwarded_ports_panel,
1410
+ );
1411
+
1412
+ async function devcontainer_refresh_ports_panel(): Promise<void> {
1413
+ if (!portsPanelOpen) return;
1414
+ await renderPortsPanel();
1415
+ editor.setStatus(editor.t("status.ports_panel_refreshed"));
1416
+ }
1417
+ registerHandler(
1418
+ "devcontainer_refresh_ports_panel",
1419
+ devcontainer_refresh_ports_panel,
1420
+ );
1421
+
1422
+ function devcontainer_close_ports_panel(): void {
1423
+ if (!portsPanelOpen) return;
1424
+ if (portsPanelSplitId !== null) {
1425
+ editor.closeSplit(portsPanelSplitId);
1426
+ }
1427
+ if (portsPanelBufferId !== null) {
1428
+ editor.closeBuffer(portsPanelBufferId);
1429
+ }
1430
+ portsPanelOpen = false;
1431
+ portsPanelBufferId = null;
1432
+ portsPanelSplitId = null;
1433
+ }
1434
+ registerHandler(
1435
+ "devcontainer_close_ports_panel",
1436
+ devcontainer_close_ports_panel,
1437
+ );
1438
+
1439
+ editor.defineMode(
1440
+ "devcontainer-ports",
1441
+ [
1442
+ ["r", "devcontainer_refresh_ports_panel"],
1443
+ ["q", "devcontainer_close_ports_panel"],
1444
+ ["Escape", "devcontainer_close_ports_panel"],
1445
+ ],
1446
+ true, // read-only
1447
+ false, // allow_text_input
1448
+ true, // inherit Normal-context bindings so arrow keys / page nav still work
1449
+ );
1450
+
1451
+ const INSTALL_COMMAND = "npm i -g @devcontainers/cli";
1452
+
1453
+ interface ActionPopupResultData {
1454
+ popup_id: string;
1455
+ action_id: string;
1456
+ }
1457
+
1458
+ function showCliNotFoundPopup(): void {
1459
+ editor.showActionPopup({
1460
+ id: "devcontainer-cli-help",
1461
+ title: editor.t("popup.cli_title"),
1462
+ message: editor.t("popup.cli_message"),
1463
+ actions: [
1464
+ { id: "copy_install", label: "Copy: " + INSTALL_COMMAND },
1465
+ { id: "dismiss", label: "Dismiss (ESC)" },
1466
+ ],
1467
+ });
1468
+ }
1469
+
1470
+
1471
+
1472
+ /// Surface a proactive action popup after a failed attach so users
1473
+ /// don't have to notice the Remote Indicator's red state on their own.
1474
+ /// Spec §8 calls for "Retry" / "Reopen Locally" on build failure; we
1475
+ /// also offer "Show Build Logs" (the file is still on disk — see
1476
+ /// `prepareBuildLogFile`) and a "Dismiss" escape so the user can come
1477
+ /// back later via the Remote Indicator menu without the popup blocking.
1478
+ ///
1479
+ /// All four actions map to existing handlers:
1480
+ /// - Retry → `devcontainer_retry_attach`
1481
+ /// - Show Build Logs → `devcontainer_show_build_logs`
1482
+ /// - Reopen Locally → `clearRemoteIndicatorState` (no authority was
1483
+ /// installed, so nothing to detach; just drop the red override).
1484
+ /// - Dismiss → no-op; FailedAttach indicator stays so the user can
1485
+ /// revisit the choice from the Remote Indicator popup.
1486
+ function showFailedAttachPopup(errText: string): void {
1487
+ editor.showActionPopup({
1488
+ id: "devcontainer-failed-attach",
1489
+ title: editor.t("popup.failed_attach_title"),
1490
+ message: editor.t("popup.failed_attach_message", { error: errText }),
1491
+ actions: [
1492
+ { id: "retry", label: editor.t("popup.failed_attach_action_retry") },
1493
+ {
1494
+ id: "show_build_logs",
1495
+ label: editor.t("popup.failed_attach_action_show_logs"),
1496
+ },
1497
+ {
1498
+ id: "reopen_local",
1499
+ label: editor.t("popup.failed_attach_action_reopen_local"),
1500
+ },
1501
+ { id: "dismiss", label: editor.t("popup.failed_attach_action_dismiss") },
1502
+ ],
1503
+ });
1504
+ }
1505
+
1506
+ function devcontainer_on_failed_attach_popup(data: ActionPopupResultData): void {
1507
+ if (data.popup_id !== "devcontainer-failed-attach") return;
1508
+ switch (data.action_id) {
1509
+ case "retry":
1510
+ void devcontainer_retry_attach();
1511
+ break;
1512
+ case "show_build_logs":
1513
+ void devcontainer_show_build_logs();
1514
+ break;
1515
+ case "reopen_local":
1516
+ // No authority was installed — failed attach never got that far —
1517
+ // so there is nothing to detach. Just drop the FailedAttach
1518
+ // override so the indicator returns to Local.
1519
+ editor.clearRemoteIndicatorState();
1520
+ break;
1521
+ case "dismiss":
1522
+ case "dismissed":
1523
+ // Leave the FailedAttach indicator visible so the user can revisit
1524
+ // via the Remote Indicator popup later.
1525
+ break;
1526
+ }
1527
+ }
1528
+ registerHandler(
1529
+ "devcontainer_on_failed_attach_popup",
1530
+ devcontainer_on_failed_attach_popup,
1531
+ );
1532
+
1533
+ /// Convenience wrapper: flip the indicator to FailedAttach, set the
1534
+ /// rebuild-failed status message, and surface the proactive action
1535
+ /// popup in one call. Every branch in `runDevcontainerUp` that reaches
1536
+ /// the failure state routes through here so the popup surfaces
1537
+ /// consistently regardless of which step failed.
1538
+ function enterFailedAttach(errText: string): void {
1539
+ editor.setStatus(editor.t("status.rebuild_failed", { error: errText }));
1540
+ editor.setRemoteIndicatorState({
1541
+ kind: "failed_attach",
1542
+ error: errText,
1543
+ });
1544
+ showFailedAttachPopup(errText);
1545
+ }
1546
+
1547
+ // =============================================================================
1548
+ // Authority lifecycle
1549
+ // =============================================================================
1550
+ //
1551
+ // "Attach" = run `devcontainer up` on the host and install a container
1552
+ // authority via editor.setAuthority({...}). The authority transition
1553
+ // restarts the editor so every cached filesystem handle / LSP / PTY
1554
+ // gets recreated against the new backend. We use spawnHostProcess for
1555
+ // the CLI call so that a plugin triggering rebuild from inside an
1556
+ // already-attached session still runs on the host, not inside the
1557
+ // container that is about to be destroyed.
1558
+
1559
+ interface DevcontainerUpResult {
1560
+ outcome?: string;
1561
+ containerId?: string;
1562
+ remoteUser?: string;
1563
+ remoteWorkspaceFolder?: string;
1564
+ }
1565
+
1566
+ function parseDevcontainerUpOutput(stdout: string): DevcontainerUpResult | null {
1567
+ const lines = stdout.split("\n");
1568
+ for (let i = lines.length - 1; i >= 0; i--) {
1569
+ const line = lines[i].trim();
1570
+ if (!line.startsWith("{")) continue;
1571
+ try {
1572
+ return JSON.parse(line) as DevcontainerUpResult;
1573
+ } catch {
1574
+ continue;
1575
+ }
1576
+ }
1577
+ return null;
1578
+ }
1579
+
1580
+ function buildContainerAuthorityPayload(
1581
+ result: DevcontainerUpResult,
1582
+ baseEnv: Array<[string, string]>,
1583
+ hostWorkspace: string | null,
1584
+ ): AuthorityPayload | null {
1585
+ if (!result.containerId) return null;
1586
+ const user = result.remoteUser ?? null;
1587
+ const workspace = result.remoteWorkspaceFolder ?? null;
1588
+
1589
+ const args: string[] = ["exec", "-it"];
1590
+ if (user) {
1591
+ args.push("-u", user);
1592
+ }
1593
+ if (workspace) {
1594
+ args.push("-w", workspace);
1595
+ }
1596
+ args.push(result.containerId, "bash", "-l");
1597
+
1598
+ const shortId = result.containerId.slice(0, 12);
1599
+
1600
+ // Plumb the host↔container workspace mapping through to the
1601
+ // authority. Without this, every LSP URI carrying a workspace path
1602
+ // is mis-translated at the host/container boundary: didOpen sends
1603
+ // host paths to the in-container LSP, and Goto-Definition responses
1604
+ // come back with container paths the editor opens verbatim on the
1605
+ // host. Both roots must be present and absolute for the mapping to
1606
+ // be useful — when either is missing we leave path_translation
1607
+ // unset and accept the (broken) status quo rather than installing a
1608
+ // half-mapping that translates one direction.
1609
+ const path_translation =
1610
+ hostWorkspace && workspace
1611
+ ? { host_root: hostWorkspace, remote_root: workspace }
1612
+ : undefined;
1613
+
1614
+ return {
1615
+ filesystem: { kind: "local" },
1616
+ spawner: {
1617
+ kind: "docker-exec",
1618
+ container_id: result.containerId,
1619
+ user,
1620
+ workspace,
1621
+ env: baseEnv,
1622
+ },
1623
+ terminal_wrapper: {
1624
+ kind: "explicit",
1625
+ command: "docker",
1626
+ args,
1627
+ manages_cwd: true,
1628
+ },
1629
+ display_label: "Container:" + shortId,
1630
+ path_translation,
1631
+ };
1632
+ }
1633
+
1634
+ /// Run `initializeCommand` on the host before container lifecycle
1635
+ /// hooks. Per the dev-container spec this is the "host-side
1636
+ /// prologue" — it runs before `devcontainer up` and has no
1637
+ /// container to be in. The `devcontainer` CLI does not invoke it
1638
+ /// automatically; Fresh is the layer that has to.
1639
+ ///
1640
+ /// Returns `true` on success or when no initializeCommand is defined;
1641
+ /// `false` and sets a user-visible failure status when the command
1642
+ /// exits non-zero, so callers can short-circuit the attach.
1643
+ async function runInitializeCommand(): Promise<boolean> {
1644
+ const cmd = config?.initializeCommand;
1645
+ if (!cmd) {
1646
+ return true;
1647
+ }
1648
+
1649
+ editor.setStatus(editor.t("status.running", { name: "initializeCommand" }));
1650
+ const cwd = editor.getCwd();
1651
+
1652
+ async function runOne(bin: string, args: string[]): Promise<number> {
1653
+ const res = await editor.spawnHostProcess(bin, args, cwd);
1654
+ return res.exit_code;
1655
+ }
1656
+
1657
+ let exitCode: number;
1658
+ if (typeof cmd === "string") {
1659
+ exitCode = await runOne("sh", ["-c", cmd]);
1660
+ } else if (Array.isArray(cmd)) {
1661
+ const [bin, ...rest] = cmd;
1662
+ exitCode = await runOne(bin, rest);
1663
+ } else {
1664
+ // Object form: run each named subcommand sequentially, bail on
1665
+ // first failure. Matches the semantics of the per-hook runner
1666
+ // in devcontainer_on_lifecycle_confirmed below.
1667
+ exitCode = 0;
1668
+ for (const [label, subcmd] of Object.entries(cmd)) {
1669
+ let bin: string;
1670
+ let args: string[];
1671
+ if (Array.isArray(subcmd)) {
1672
+ [bin, ...args] = subcmd;
1673
+ } else {
1674
+ bin = "sh";
1675
+ args = ["-c", subcmd as string];
1676
+ }
1677
+ editor.setStatus(
1678
+ editor.t("status.running_sub", { name: "initializeCommand", label }),
1679
+ );
1680
+ const res = await runOne(bin, args);
1681
+ if (res !== 0) {
1682
+ exitCode = res;
1683
+ editor.setStatus(
1684
+ editor.t("status.failed_sub", {
1685
+ name: "initializeCommand",
1686
+ label,
1687
+ code: String(res),
1688
+ }),
1689
+ );
1690
+ return false;
1691
+ }
1692
+ }
1693
+ }
1694
+
1695
+ if (exitCode !== 0) {
1696
+ editor.setStatus(
1697
+ editor.t("status.failed", {
1698
+ name: "initializeCommand",
1699
+ code: String(exitCode),
1700
+ }),
1701
+ );
1702
+ return false;
1703
+ }
1704
+ return true;
1705
+ }
1706
+
1707
+ async function runDevcontainerUp(extraArgs: string[]): Promise<void> {
1708
+ const cwd = editor.getCwd();
1709
+ const which = await editor.spawnHostProcess("which", ["devcontainer"]);
1710
+ if (which.exit_code !== 0) {
1711
+ showCliNotFoundPopup();
1712
+ return;
1713
+ }
1714
+
1715
+ // The Remote Indicator goes into "Connecting · <phase>" for the
1716
+ // duration of the attach so users see progress; cleared (or
1717
+ // replaced with FailedAttach) by the explicit transitions below.
1718
+ editor.setRemoteIndicatorState({
1719
+ kind: "connecting",
1720
+ label: editor.t("indicator.phase_initialize"),
1721
+ });
1722
+
1723
+ // initializeCommand runs on the host BEFORE `devcontainer up`, per
1724
+ // spec. Bail the attach if it fails; the user shouldn't get an
1725
+ // attached container after their host-side prologue errored.
1726
+ if (!(await runInitializeCommand())) {
1727
+ enterFailedAttach(editor.t("indicator.error_initialize"));
1728
+ return;
1729
+ }
1730
+
1731
+ editor.setRemoteIndicatorState({
1732
+ kind: "connecting",
1733
+ label: editor.t("indicator.phase_build"),
1734
+ });
1735
+ editor.setStatus(editor.t("status.rebuilding"));
1736
+
1737
+ // Redirect `devcontainer up`'s stderr into a workspace-scoped log
1738
+ // file; let stdout flow back through the existing pipe so we parse
1739
+ // the success JSON from `result.stdout` as before. This mirrors
1740
+ // the CLI's stream contract: stdout = machine-readable result;
1741
+ // stderr = human-readable progress / errors. The log file holds
1742
+ // exactly the "progress/errors" half.
1743
+ //
1744
+ // Rationale for the file:
1745
+ // - "Show Build Logs" is just `openFile(path)` — no new API.
1746
+ // - Fresh's auto-revert (2s poll) streams lines into the buffer
1747
+ // as they arrive; user sees live progress without special
1748
+ // plumbing.
1749
+ // - Path is under the workspace, so bind-mount coincidence keeps
1750
+ // it reachable post-attach (container auth sees the same file).
1751
+ // - `.fresh-cache/.gitignore = *` self-ignores the cache dir
1752
+ // without forcing users to touch their own `.gitignore`.
1753
+ const logPath = await prepareBuildLogFile(cwd);
1754
+ if (!logPath) {
1755
+ enterFailedAttach(editor.t("status.build_log_prepare_failed"));
1756
+ return;
1757
+ }
1758
+ rememberLastBuildLogPath(logPath);
1759
+ // Drop any session-restored build logs from previous runs before
1760
+ // opening the fresh one. Without this, `Show Build Logs` after a
1761
+ // cold restart would race the freshly-minted timestamp file against
1762
+ // a stale one in another split, with no visual cue which is which.
1763
+ closeStaleBuildLogBuffers(cwd);
1764
+ // Open the log in a split below so the user sees lines stream in
1765
+ // (auto-revert polls every 2s) without losing the buffer they were
1766
+ // editing. `split_horizontal` duplicates the current buffer into a
1767
+ // new split and focuses it; openFile then swaps the new split's
1768
+ // buffer for the log. Non-fatal if either step fails — the build
1769
+ // continues either way.
1770
+ openBuildLogInSplit(logPath);
1771
+
1772
+ // `sh -c 'exec devcontainer "$@" 2> "$LOG"' sh <log> <args...>` —
1773
+ // positional-arg form so the log path and cwd never get
1774
+ // string-interpolated into the script body. $1 is the log path;
1775
+ // `shift` drops it; `$@` is the devcontainer invocation.
1776
+ const shellScript = 'LOG="$1"; shift; exec devcontainer "$@" 2> "$LOG"';
1777
+ const args = [
1778
+ "-c",
1779
+ shellScript,
1780
+ "sh",
1781
+ logPath,
1782
+ "up",
1783
+ "--workspace-folder",
1784
+ cwd,
1785
+ ...extraArgs,
1786
+ ];
1787
+ const handle = editor.spawnHostProcess("sh", args);
1788
+ attachInFlight = handle;
1789
+ attachCancelled = false;
1790
+ let result: SpawnResult;
1791
+ try {
1792
+ result = await handle;
1793
+ } finally {
1794
+ attachInFlight = null;
1795
+ }
1796
+
1797
+ // Cancel path: `devcontainer_cancel_attach` set `attachCancelled`
1798
+ // and flipped the indicator back to Local already. The non-zero
1799
+ // exit coming out of `Child::start_kill()` is not an error.
1800
+ if (attachCancelled) {
1801
+ attachCancelled = false;
1802
+ return;
1803
+ }
1804
+
1805
+ if (result.exit_code !== 0) {
1806
+ // On failure the log file holds the stderr trace — surface its
1807
+ // last non-empty line as a human-readable status blurb. This
1808
+ // is purely cosmetic; exit_code drove the branch.
1809
+ const logText = editor.readFile(logPath) ?? "";
1810
+ const errText = extractLastNonEmptyLine(logText)
1811
+ ?? `exit ${result.exit_code}`;
1812
+ enterFailedAttach(errText);
1813
+ return;
1814
+ }
1815
+
1816
+ const parsed = parseDevcontainerUpOutput(result.stdout);
1817
+ if (!parsed || parsed.outcome !== "success" || !parsed.containerId) {
1818
+ enterFailedAttach(editor.t("status.rebuild_parse_failed"));
1819
+ return;
1820
+ }
1821
+
1822
+ // Capture the in-container `userEnvProbe` env BEFORE we hand the
1823
+ // payload to setAuthority. setAuthority restarts the editor; once
1824
+ // the new editor's spawner is wired with the captured PATH, LSP
1825
+ // `command_exists` and `spawn_stdio` see the same binaries the
1826
+ // user's interactive shell sees (e.g. `pylsp` installed by a
1827
+ // `postCreateCommand` into `~/.local/bin`). Per spec, when
1828
+ // `userEnvProbe` is unset the default is `loginInteractiveShell`.
1829
+ const baseEnv = await captureContainerLoginEnv(parsed);
1830
+
1831
+ // Capture the host workspace path so the authority can translate
1832
+ // LSP URIs at the host↔container boundary. `editor.getCwd()` is
1833
+ // the production host workspace today; we read it just before
1834
+ // setAuthority so the value matches the editor that's about to be
1835
+ // rebuilt.
1836
+ const hostWorkspace = editor.getCwd();
1837
+ const payload = buildContainerAuthorityPayload(parsed, baseEnv, hostWorkspace);
1838
+ if (!payload) {
1839
+ enterFailedAttach(editor.t("status.rebuild_missing_container_id"));
1840
+ return;
1841
+ }
1842
+
1843
+ // setAuthority fires the restart flow in core. The status message
1844
+ // we set here won't survive the restart; the plugin will re-init
1845
+ // with the new authority active and print status.detected again.
1846
+ //
1847
+ // Write the attempt breadcrumb immediately before so the post-
1848
+ // restart plugin instance can detect "attach was in flight" and
1849
+ // decide between success (container authority live) and silent
1850
+ // failure (no authority landed — surfaces as FailedAttach).
1851
+ writeAttachAttempt();
1852
+ // Persist `remoteWorkspaceFolder` so the post-restart plugin
1853
+ // instance can pass it as the cwd to lifecycle commands. The
1854
+ // runtime's `spawnProcess` auto-fills working_dir (host path)
1855
+ // when cwd is omitted — that breaks `docker exec -w` for
1856
+ // configs whose `workspaceFolder` differs from the host
1857
+ // workspace path. See `lifecycleCwd()`.
1858
+ writeRemoteWorkspace(parsed.remoteWorkspaceFolder ?? null);
1859
+ editor.setAuthority(payload);
1860
+ }
1861
+
1862
+ // Lay out `.fresh-cache/devcontainer-logs/<timestamp>.log` under the
1863
+ // workspace. Returns the log path on success, null on failure
1864
+ // (mkdir denied, etc.). The directory carries its own
1865
+ // `.gitignore = *` so the cache never leaks into a commit without
1866
+ // the user touching their top-level `.gitignore`.
1867
+ async function prepareBuildLogFile(cwd: string): Promise<string | null> {
1868
+ const cacheDir = `${cwd}/.fresh-cache`;
1869
+ const logDir = `${cacheDir}/devcontainer-logs`;
1870
+ const mkRes = await editor.spawnHostProcess("mkdir", ["-p", logDir]);
1871
+ if (mkRes.exit_code !== 0) {
1872
+ editor.debug(
1873
+ `devcontainer: mkdir -p ${logDir} failed: ${mkRes.stderr.trim()}`,
1874
+ );
1875
+ return null;
1876
+ }
1877
+ const cacheIgnore = `${cacheDir}/.gitignore`;
1878
+ if (editor.readFile(cacheIgnore) === null) {
1879
+ // writeFile failure is non-fatal — worst case the user sees
1880
+ // `.fresh-cache/` in `git status` once.
1881
+ editor.writeFile(cacheIgnore, "*\n");
1882
+ }
1883
+ // `toISOString()` → "2026-04-21T12:34:56.789Z"; strip the ms+Z
1884
+ // and swap separators that are awkward in filenames on some
1885
+ // platforms.
1886
+ const ts = new Date()
1887
+ .toISOString()
1888
+ .replace(/\.\d+Z$/, "")
1889
+ .replace(/:/g, "-")
1890
+ .replace("T", "_");
1891
+ return `${logDir}/build-${ts}.log`;
1892
+ }
1893
+
1894
+ function lastBuildLogKey(): string {
1895
+ return "last-build-log:" + editor.getCwd();
1896
+ }
1897
+
1898
+ /// Open the build log file in a horizontal split below the current
1899
+ /// pane, leaving whatever the user was editing in the top split. Used
1900
+ /// both during the live build (so users see progress without losing
1901
+ /// their working buffer) and from `devcontainer_show_build_logs` so
1902
+ /// the post-attach access path doesn't replace the user's file
1903
+ /// either.
1904
+ ///
1905
+ /// Dedupe uses `BufferInfo.splits` from `listBuffers()` — if the log
1906
+ /// is already visible in some split, focus that split. Otherwise
1907
+ /// split + openFile. Reading the current snapshot each call (rather
1908
+ /// than tracking split ids in module state) means the dedupe
1909
+ /// survives the post-attach editor restart: after setAuthority
1910
+ /// rebuilds the editor and workspace restore brings the log buffer
1911
+ /// back, the first `Show Build Logs` finds the restored split and
1912
+ /// focuses it instead of stacking a new one on top.
1913
+ /// Resolve the split id to drop a panel into. If we have a
1914
+ /// previously-claimed panel split that's still alive, reuse it.
1915
+ /// Otherwise grab the currently focused split — never spawn a
1916
+ /// new one. Returns the chosen split id.
1917
+ function resolvePanelSplit(): number {
1918
+ if (panelSplitId !== null) {
1919
+ const stillAlive = editor.listSplits().some((s) => s.splitId === panelSplitId);
1920
+ if (stillAlive) return panelSplitId;
1921
+ panelSplitId = null;
1922
+ }
1923
+ const active = editor.getActiveSplitId();
1924
+ panelSplitId = active;
1925
+ return active;
1926
+ }
1927
+
1928
+ /// Per-name registry of panel buffers. Keyed by the
1929
+ /// `*Dev Container*` / `*Dev Container Logs*` etc. names so
1930
+ /// re-running a Show command refreshes its own buffer in place
1931
+ /// without destroying the other Show commands' buffers. Lives
1932
+ /// in module state — resets on plugin reload, which is fine
1933
+ /// since buffer ids change across editor restarts anyway and
1934
+ /// the next Show * invocation will (re)create as needed.
1935
+ const namedPanelBuffers = new Map<string, number>();
1936
+
1937
+ /// Drop a virtual buffer into the shared panel slot.
1938
+ ///
1939
+ /// Per-name dedupe: re-running the same Show command refreshes
1940
+ /// the existing same-name buffer in place (`setVirtualBufferContent`)
1941
+ /// rather than creating a new one. Different Show commands keep
1942
+ /// their own buffers — `Show Container Logs` no longer wipes out
1943
+ /// `Show Container Info`'s buffer, so the user can switch back to
1944
+ /// it via the tab bar / buffer list.
1945
+ ///
1946
+ /// Returns the new buffer id, or null if the runtime call failed.
1947
+ async function openVirtualInPanelSlot(opts: {
1948
+ name: string;
1949
+ mode: string;
1950
+ entries: TextPropertyEntry[];
1951
+ readOnly?: boolean;
1952
+ showLineNumbers?: boolean;
1953
+ showCursors?: boolean;
1954
+ editingDisabled?: boolean;
1955
+ lineWrap?: boolean;
1956
+ }): Promise<{ bufferId: number; splitId: number } | null> {
1957
+ const splitId = resolvePanelSplit();
1958
+ const liveBuffers = editor.listBuffers();
1959
+
1960
+ // If we already have a same-name buffer (and it's still alive),
1961
+ // refresh its contents and surface it in the panel split rather
1962
+ // than minting a duplicate. This is the per-command-buffer fix:
1963
+ // running `Show Container Logs` twice updates one buffer instead
1964
+ // of stacking two; running it then `Show Container Info` keeps
1965
+ // both alive.
1966
+ const existingId = namedPanelBuffers.get(opts.name);
1967
+ if (existingId !== undefined) {
1968
+ const existing = liveBuffers.find((b) => b.id === existingId);
1969
+ if (existing) {
1970
+ editor.setVirtualBufferContent(
1971
+ existing.id,
1972
+ opts.entries as unknown as Record<string, unknown>[],
1973
+ );
1974
+ editor.focusSplit(splitId);
1975
+ editor.showBuffer(existing.id);
1976
+ panelBufferIds.add(existing.id);
1977
+ return { bufferId: existing.id, splitId };
1978
+ }
1979
+ namedPanelBuffers.delete(opts.name);
1980
+ }
1981
+
1982
+ const result = await editor.createVirtualBufferInExistingSplit({
1983
+ name: opts.name,
1984
+ splitId,
1985
+ mode: opts.mode,
1986
+ readOnly: opts.readOnly ?? true,
1987
+ showLineNumbers: opts.showLineNumbers ?? false,
1988
+ showCursors: opts.showCursors ?? true,
1989
+ editingDisabled: opts.editingDisabled ?? true,
1990
+ lineWrap: opts.lineWrap ?? false,
1991
+ entries: opts.entries,
1992
+ });
1993
+ if (result === null) return null;
1994
+ panelBufferIds.add(result.bufferId);
1995
+ namedPanelBuffers.set(opts.name, result.bufferId);
1996
+ return { bufferId: result.bufferId, splitId };
1997
+ }
1998
+
1999
+ /// File-backed equivalent for build-log files: focuses the
2000
+ /// panel-slot split and opens the file there. `openFile` is
2001
+ /// idempotent by path, so re-running `Show Build Logs` for the
2002
+ /// same file just brings the existing buffer to the front; we
2003
+ /// don't need our own dedupe layer.
2004
+ ///
2005
+ /// We also turn line-wrap off — build logs are wide structured
2006
+ /// output (docker buildx, lifecycle stdout) that's much more
2007
+ /// readable scrolled horizontally than soft-wrapped at column 80.
2008
+ function openFileInPanelSlot(path: string): void {
2009
+ const splitId = resolvePanelSplit();
2010
+ editor.focusSplit(splitId);
2011
+ editor.openFile(path, null, null);
2012
+ // After the file is open, find its buffer id and disable
2013
+ // line wrap. The new-buffer case sets this on first open;
2014
+ // subsequent invocations are no-ops.
2015
+ const opened = editor.listBuffers().find((b) => b.path === path);
2016
+ if (opened) {
2017
+ editor.setLineWrap(opened.id, null, false);
2018
+ panelBufferIds.add(opened.id);
2019
+ }
2020
+ }
2021
+
2022
+ function openBuildLogInSplit(path: string): void {
2023
+ openFileInPanelSlot(path);
2024
+ }
2025
+
2026
+ /// Close every open build-log buffer for this workspace before the new
2027
+ /// attach mints its own log. Without this, a session-restored buffer
2028
+ /// (whose contents are stale from the previous run) sits next to the
2029
+ /// fresh streaming log and the user has to guess which one is live.
2030
+ ///
2031
+ /// Pure heuristic: any buffer whose path lives under
2032
+ /// `<cwd>/.fresh-cache/devcontainer-logs/` is a build log. The
2033
+ /// directory is plugin-owned (see `prepareBuildLogFile`), so the
2034
+ /// false-positive surface is empty unless a user puts arbitrary files
2035
+ /// there themselves — at which point closing them on attach is also
2036
+ /// the right call.
2037
+ function closeStaleBuildLogBuffers(cwd: string): void {
2038
+ const prefix = editor.pathJoin(cwd, ".fresh-cache", "devcontainer-logs");
2039
+ const buffers = editor.listBuffers();
2040
+ for (const b of buffers) {
2041
+ if (b.path && b.path.startsWith(prefix)) {
2042
+ editor.closeBuffer(b.id);
2043
+ }
2044
+ }
2045
+ }
2046
+
2047
+ function rememberLastBuildLogPath(path: string): void {
2048
+ editor.setGlobalState(lastBuildLogKey(), path);
2049
+ }
2050
+
2051
+ function readLastBuildLogPath(): string | null {
2052
+ const raw = editor.getGlobalState(lastBuildLogKey()) as unknown;
2053
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
2054
+ }
2055
+
2056
+ function extractLastNonEmptyLine(text: string): string | null {
2057
+ const lines = text.split("\n");
2058
+ for (let i = lines.length - 1; i >= 0; i--) {
2059
+ const t = lines[i].trim();
2060
+ if (t.length > 0) return t;
2061
+ }
2062
+ return null;
2063
+ }
2064
+
2065
+ async function devcontainer_attach(): Promise<void> {
2066
+ if (!config) {
2067
+ editor.setStatus(editor.t("status.no_config"));
2068
+ return;
2069
+ }
2070
+ await runDevcontainerUp([]);
2071
+ }
2072
+ registerHandler("devcontainer_attach", devcontainer_attach);
2073
+
2074
+ async function devcontainer_rebuild(): Promise<void> {
2075
+ if (!config) {
2076
+ editor.setStatus(editor.t("status.no_config"));
2077
+ return;
2078
+ }
2079
+ await runDevcontainerUp(["--remove-existing-container"]);
2080
+ }
2081
+ registerHandler("devcontainer_rebuild", devcontainer_rebuild);
2082
+
2083
+ /// Retry a previously-failed attach. Thin wrapper around
2084
+ /// `devcontainer_attach` — exists so the Remote Indicator popup's
2085
+ /// FailedAttach branch can dispatch something named `retry_attach`
2086
+ /// without hard-coding an implementation detail. Also the natural
2087
+ /// single call site if we ever want to add backoff or attempt
2088
+ /// counting.
2089
+ async function devcontainer_retry_attach(): Promise<void> {
2090
+ // Drop the stale FailedAttach state before the new attempt so
2091
+ // the popup shows the freshly-entered Connecting state
2092
+ // immediately; setRemoteIndicatorState inside runDevcontainerUp
2093
+ // will override again.
2094
+ editor.clearRemoteIndicatorState();
2095
+ await devcontainer_attach();
2096
+ }
2097
+ registerHandler("devcontainer_retry_attach", devcontainer_retry_attach);
2098
+
2099
+ async function devcontainer_detach(): Promise<void> {
2100
+ // Honor `shutdownAction` per spec: default for image/Dockerfile
2101
+ // is `stopContainer`. Stop the container BEFORE clearing
2102
+ // authority — clearing the authority drops our spawner, so we'd
2103
+ // lose the easy way to issue `docker stop` against the right
2104
+ // daemon. Use `spawnHostProcess` because the container is about
2105
+ // to disappear; routing through the soon-to-be-cleared container
2106
+ // authority makes no sense.
2107
+ await stopContainerIfShutdownActionRequires();
2108
+ editor.clearAuthority();
2109
+ }
2110
+ registerHandler("devcontainer_detach", devcontainer_detach);
2111
+
2112
+ /// If `shutdownAction` says to stop the container (default for
2113
+ /// image/Dockerfile), spawn `docker stop <id>` on the host.
2114
+ /// No-op for `none` / `stopCompose` (compose has its own
2115
+ /// teardown the plugin doesn't drive). Failures are logged but
2116
+ /// don't block the detach itself — the user's intent is to stop
2117
+ /// using the container, and forcing them to keep it because
2118
+ /// `docker stop` errored would be worse than leaving an orphan.
2119
+ async function stopContainerIfShutdownActionRequires(): Promise<void> {
2120
+ const action = config?.shutdownAction ?? "stopContainer";
2121
+ if (action !== "stopContainer") return;
2122
+
2123
+ const label = editor.getAuthorityLabel();
2124
+ const prefix = "Container:";
2125
+ if (!label.startsWith(prefix)) return;
2126
+ const containerId = label.slice(prefix.length);
2127
+ if (containerId.length === 0) return;
2128
+
2129
+ const which = await editor.spawnHostProcess("which", ["docker"]);
2130
+ if (which.exit_code !== 0) {
2131
+ editor.debug(`devcontainer: docker not on PATH; skipping shutdownAction=stopContainer`);
2132
+ return;
2133
+ }
2134
+ const result = await editor.spawnHostProcess("docker", ["stop", containerId]);
2135
+ if (result.exit_code !== 0) {
2136
+ editor.debug(
2137
+ `devcontainer: docker stop ${containerId} exited ${result.exit_code}: ${result.stderr.trim()}`,
2138
+ );
2139
+ }
2140
+ }
2141
+
2142
+ /// Abort an in-flight attach by killing the `devcontainer up` host
2143
+ /// spawn. No-op when nothing is in flight. The indicator is flipped
2144
+ /// back to Local immediately — cancel is a user-initiated revert,
2145
+ /// not a failure, so we don't go through FailedAttach.
2146
+ async function devcontainer_cancel_attach(): Promise<void> {
2147
+ const handle = attachInFlight;
2148
+ if (!handle) {
2149
+ editor.setStatus(editor.t("status.cancel_nothing_in_flight"));
2150
+ return;
2151
+ }
2152
+ // Order matters: set the flag before kill() so the awaiting
2153
+ // runDevcontainerUp sees `attachCancelled = true` when the
2154
+ // terminal event arrives, and takes the silent-return path
2155
+ // instead of painting FailedAttach on top of the Local we're
2156
+ // about to install.
2157
+ attachCancelled = true;
2158
+ editor.setRemoteIndicatorState({ kind: "local" });
2159
+ editor.setStatus(editor.t("status.attach_cancelled"));
2160
+ // `.kill()` returns a Promise<boolean> from the TS wrapper — we
2161
+ // don't need the boolean; the kill is fire-and-forget.
2162
+ void handle.kill();
2163
+ }
2164
+ registerHandler("devcontainer_cancel_attach", devcontainer_cancel_attach);
2165
+
2166
+ /// Open the build log from the most recent `devcontainer up` in a
2167
+ /// buffer. The path was remembered across restarts via
2168
+ /// `setGlobalState`, so this works both during Connecting (log is
2169
+ /// still being appended — Fresh's auto-revert shows live updates)
2170
+ /// and after a FailedAttach / successful attach.
2171
+ async function devcontainer_show_build_logs(): Promise<void> {
2172
+ const path = readLastBuildLogPath();
2173
+ if (!path) {
2174
+ editor.setStatus(editor.t("status.no_build_log"));
2175
+ return;
2176
+ }
2177
+ if (editor.readFile(path) === null) {
2178
+ editor.setStatus(editor.t("status.build_log_missing"));
2179
+ return;
2180
+ }
2181
+ openBuildLogInSplit(path);
2182
+ }
2183
+ registerHandler("devcontainer_show_build_logs", devcontainer_show_build_logs);
2184
+
2185
+ /// Show a one-shot snapshot of the attached container's stdout/stderr
2186
+ /// via `docker logs --tail 1000 <id>`. The log is rendered into a
2187
+ /// read-only virtual buffer split; closing the split discards the
2188
+ /// snapshot (re-run the command for a refresh).
2189
+ ///
2190
+ /// Host-side by design: we talk to the `docker` CLI from outside the
2191
+ /// container so this works even when the container is mid-reboot or
2192
+ /// has no shell. The container id comes from the active authority's
2193
+ /// display label ("Container:<shortid>") rather than re-parsing the
2194
+ /// `devcontainer up` JSON — plugins own the authority surface, core
2195
+ /// owns the label.
2196
+ async function devcontainer_show_logs(): Promise<void> {
2197
+ const authorityLabel = editor.getAuthorityLabel();
2198
+ const prefix = "Container:";
2199
+ if (!authorityLabel.startsWith(prefix)) {
2200
+ editor.setStatus(editor.t("status.logs_require_container"));
2201
+ return;
2202
+ }
2203
+ const containerId = authorityLabel.slice(prefix.length);
2204
+ if (containerId.length === 0) {
2205
+ editor.setStatus(editor.t("status.logs_require_container"));
2206
+ return;
2207
+ }
2208
+
2209
+ const which = await editor.spawnHostProcess("which", ["docker"]);
2210
+ if (which.exit_code !== 0) {
2211
+ editor.setStatus(editor.t("status.logs_docker_missing"));
2212
+ return;
2213
+ }
2214
+
2215
+ editor.setStatus(editor.t("status.logs_loading"));
2216
+ const res = await editor.spawnHostProcess(
2217
+ "docker",
2218
+ ["logs", "--tail", "1000", containerId],
2219
+ editor.getCwd(),
2220
+ );
2221
+
2222
+ // `docker logs` emits container stdout on our stdout and container
2223
+ // stderr on our stderr — merge them with a leading marker so the
2224
+ // user can tell them apart in the buffer.
2225
+ const mergedParts: string[] = [];
2226
+ if (res.stdout.length > 0) {
2227
+ mergedParts.push(res.stdout);
2228
+ }
2229
+ if (res.stderr.length > 0) {
2230
+ mergedParts.push("--- stderr ---\n" + res.stderr);
2231
+ }
2232
+ const merged = mergedParts.join("\n").length > 0
2233
+ ? mergedParts.join("\n")
2234
+ : editor.t("status.logs_empty");
2235
+
2236
+ // Bug #6 retest: was always splitting on every invocation
2237
+ // (no dedupe at all). Route through the shared panel slot.
2238
+ const result = await openVirtualInPanelSlot({
2239
+ name: "*Dev Container Logs*",
2240
+ mode: "devcontainer-info",
2241
+ entries: [{ text: merged, properties: { type: "log" } }],
2242
+ });
2243
+ if (result !== null) {
2244
+ editor.setStatus(editor.t("status.logs_shown"));
2245
+ }
2246
+ }
2247
+ registerHandler("devcontainer_show_logs", devcontainer_show_logs);
2248
+
2249
+ // =============================================================================
2250
+ // Scaffold
2251
+ // =============================================================================
2252
+
2253
+ /// Write a minimal `.devcontainer/devcontainer.json` when the workspace
2254
+ /// doesn't have one yet, and open it for editing. The template is
2255
+ /// deliberately conservative — the user picks an image and tweaks
2256
+ /// lifecycle hooks from there. Matches the spec's "Configure Dev
2257
+ /// Container" entry for the Local branch of the Remote Indicator
2258
+ /// popup.
2259
+ function devcontainer_scaffold_config(): void {
2260
+ const cwd = editor.getCwd();
2261
+ const dcDir = editor.pathJoin(cwd, ".devcontainer");
2262
+ const configFile = editor.pathJoin(dcDir, "devcontainer.json");
2263
+
2264
+ // Respect an existing config — always a safer default than
2265
+ // overwriting. The user can call `devcontainer_open_config` if they
2266
+ // just meant to edit it.
2267
+ if (editor.fileExists(configFile)) {
2268
+ editor.setStatus(editor.t("status.scaffold_already_exists"));
2269
+ editor.openFile(configFile, null, null);
2270
+ return;
2271
+ }
2272
+
2273
+ if (!editor.createDir(dcDir)) {
2274
+ editor.setStatus(editor.t("status.scaffold_failed"));
2275
+ return;
2276
+ }
2277
+
2278
+ const workspaceName = cwd.split("/").filter(Boolean).pop() ?? "workspace";
2279
+ const template =
2280
+ JSON.stringify(
2281
+ {
2282
+ name: workspaceName,
2283
+ image: "mcr.microsoft.com/devcontainers/base:ubuntu",
2284
+ },
2285
+ null,
2286
+ 2,
2287
+ ) + "\n";
2288
+
2289
+ if (!editor.writeFile(configFile, template)) {
2290
+ editor.setStatus(editor.t("status.scaffold_failed"));
2291
+ return;
2292
+ }
2293
+
2294
+ // Refresh the in-memory config so a subsequent "Reopen in Container"
2295
+ // uses the new file without requiring a plugin reload.
2296
+ try {
2297
+ config = editor.parseJsonc(template) as DevContainerConfig;
2298
+ configPath = configFile;
2299
+ registerCommands();
2300
+ } catch (e) {
2301
+ editor.debug("devcontainer: scaffold parse failed: " + String(e));
2302
+ }
2303
+
2304
+ editor.setStatus(editor.t("status.scaffold_created"));
2305
+ editor.openFile(configFile, null, null);
2306
+ }
2307
+ registerHandler("devcontainer_scaffold_config", devcontainer_scaffold_config);
2308
+
2309
+ // =============================================================================
2310
+ // One-shot attach prompt
2311
+ // =============================================================================
2312
+ //
2313
+ // When the plugin loads and a devcontainer.json is found, check whether
2314
+ // we've already asked the user about this workspace. If not, surface a
2315
+ // one-shot "attach?" popup. The answer is remembered per-workspace via
2316
+ // plugin global state (keyed by cwd) so reopening the same project
2317
+ // doesn't re-prompt every time.
2318
+
2319
+ type AttachDecision = "attached" | "dismissed";
2320
+
2321
+ function attachDecisionKey(): string {
2322
+ return "attach:" + editor.getCwd();
2323
+ }
2324
+
2325
+ function readAttachDecision(): AttachDecision | null {
2326
+ const raw = editor.getGlobalState(attachDecisionKey()) as unknown;
2327
+ if (raw === "attached" || raw === "dismissed") return raw;
2328
+ return null;
2329
+ }
2330
+
2331
+ function writeAttachDecision(value: AttachDecision): void {
2332
+ editor.setGlobalState(attachDecisionKey(), value);
2333
+ }
2334
+
2335
+ /// In-memory "Ignore (once)" — true after the user picks the
2336
+ /// session-only Ignore option in the attach popup. Cleared on
2337
+ /// plugin reload, which means the next editor restart asks again.
2338
+ /// This is deliberately separate from the persisted "Ignore (always)"
2339
+ /// decision (`writeAttachDecision("dismissed")`) so users have a
2340
+ /// real choice between "not now" and "stop asking forever".
2341
+ let attachDismissedThisSession = false;
2342
+
2343
+ /// Breadcrumb written before calling `editor.setAuthority(payload)`
2344
+ /// — setAuthority restarts the editor, so there's no clean callback
2345
+ /// to hook once the new authority is live. If the post-restart plugin
2346
+ /// instance sees this key with no matching container authority
2347
+ /// installed, the attach round-tripped through setAuthority but the
2348
+ /// core failed to construct the authority (rare: a rejected
2349
+ /// AuthorityPayload). We surface that as FailedAttach so users aren't
2350
+ /// stuck wondering why Connecting silently became Local.
2351
+ ///
2352
+ /// The key carries the epoch-ms timestamp of the attempt so stale
2353
+ /// entries from long-dormant sessions don't bleed into a fresh
2354
+ /// attach years later.
2355
+ function attachAttemptKey(): string {
2356
+ return "attach-attempt:" + editor.getCwd();
2357
+ }
2358
+
2359
+ function writeAttachAttempt(): void {
2360
+ editor.setGlobalState(attachAttemptKey(), String(Date.now()));
2361
+ }
2362
+
2363
+ function clearAttachAttempt(): void {
2364
+ editor.setGlobalState(attachAttemptKey(), null);
2365
+ }
2366
+
2367
+ function readAttachAttemptMs(): number | null {
2368
+ const raw = editor.getGlobalState(attachAttemptKey()) as unknown;
2369
+ if (typeof raw === "string") {
2370
+ const n = Number(raw);
2371
+ return Number.isFinite(n) ? n : null;
2372
+ }
2373
+ return null;
2374
+ }
2375
+
2376
+ function showAttachPrompt(): void {
2377
+ editor.showActionPopup({
2378
+ id: "devcontainer-attach",
2379
+ title: editor.t("popup.attach_title"),
2380
+ message: editor.t("popup.attach_message", {
2381
+ name: config?.name ?? "unnamed",
2382
+ }),
2383
+ actions: [
2384
+ { id: "attach", label: editor.t("popup.attach_action_attach") },
2385
+ { id: "dismiss_once", label: editor.t("popup.attach_action_dismiss_once") },
2386
+ { id: "dismiss_always", label: editor.t("popup.attach_action_dismiss_always") },
2387
+ ],
2388
+ });
2389
+ }
2390
+
2391
+ function devcontainer_on_attach_popup(data: ActionPopupResultData): void {
2392
+ if (data.popup_id !== "devcontainer-attach") return;
2393
+ if (data.action_id === "attach") {
2394
+ writeAttachDecision("attached");
2395
+ // Fire and forget: runDevcontainerUp's setAuthority call restarts
2396
+ // the editor, so nothing after this runs anyway.
2397
+ void devcontainer_attach();
2398
+ } else if (data.action_id === "dismiss_always") {
2399
+ // Persistent ignore: write to plugin global state so the next
2400
+ // editor restart in this workspace finds the breadcrumb and
2401
+ // skips the popup entirely.
2402
+ writeAttachDecision("dismissed");
2403
+ } else {
2404
+ // `dismiss_once` (or the legacy `dismiss` id from older
2405
+ // popups whose state is replayed mid-session): in-memory flag
2406
+ // only. The next editor restart in this workspace re-asks.
2407
+ attachDismissedThisSession = true;
2408
+ }
2409
+ }
2410
+ registerHandler("devcontainer_on_attach_popup", devcontainer_on_attach_popup);
2411
+
2412
+ // =============================================================================
2413
+ // Event Handlers
2414
+ // =============================================================================
2415
+
2416
+ editor.on("prompt_confirmed", async (data) => {
2417
+ if (data.prompt_type !== "devcontainer-lifecycle") return;
2418
+
2419
+ const cmdName = data.input;
2420
+ if (!config || !cmdName) return;
2421
+
2422
+ const cmd = (config as Record<string, unknown>)[cmdName] as LifecycleCommand | undefined;
2423
+ if (!cmd) return;
2424
+
2425
+ // cwd: when attached to a Container, pass the in-container
2426
+ // `remoteWorkspaceFolder` so `docker exec -w` lands inside
2427
+ // the container. When local, pass "" — the runtime treats
2428
+ // empty-string cwd the same as omitted (both fall back to
2429
+ // working_dir). Avoids passing literal `undefined` through
2430
+ // the QuickJS bridge, which the marshaller rejects with
2431
+ // "Error converting from js 'undefined' into type 'string'".
2432
+ const cwd = lifecycleCwd() ?? "";
2433
+ const env = await effectiveLifecycleEnv();
2434
+ if (typeof cmd === "string") {
2435
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
2436
+ const [bin, args] = wrapWithEnv(env, "sh", ["-c", cmd]);
2437
+ const result = await editor.spawnProcess(bin, args, cwd);
2438
+ await surfaceLifecycleResult(cmdName, null, cmd, result);
2439
+ } else if (Array.isArray(cmd)) {
2440
+ const [origBin, ...origArgs] = cmd;
2441
+ const [bin, args] = wrapWithEnv(env, origBin, origArgs);
2442
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
2443
+ const result = await editor.spawnProcess(bin, args, cwd);
2444
+ await surfaceLifecycleResult(cmdName, null, [origBin, ...origArgs].join(" "), result);
2445
+ } else {
2446
+ // Object form: see the rewritten parallel branch in
2447
+ // `runLifecycleObjectForm`.
2448
+ await runLifecycleObjectForm(cmdName, cmd);
2449
+ }
2450
+ });
2451
+ editor.on("action_popup_result", (data) => {
2452
+ if (data.popup_id === "devcontainer-cli-help") {
2453
+ switch (data.action_id) {
2454
+ case "copy_install":
2455
+ editor.setClipboard(INSTALL_COMMAND);
2456
+ editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
2457
+ break;
2458
+ case "dismiss":
2459
+ case "dismissed":
2460
+ break;
2461
+ }
2462
+ return;
2463
+ }
2464
+ if (data.popup_id === "devcontainer-attach") {
2465
+ devcontainer_on_attach_popup(data);
2466
+ return;
2467
+ }
2468
+ if (data.popup_id === "devcontainer-failed-attach") {
2469
+ devcontainer_on_failed_attach_popup(data);
2470
+ }
2471
+ });
2472
+ editor.on("authority_changed", (data) => {
2473
+ registerCommands();
2474
+ const label = (data as { label?: string } | undefined)?.label ?? "";
2475
+ if (label.startsWith("Container:")) {
2476
+ void runAutoForwardSweep();
2477
+ } else {
2478
+ notifiedPorts.clear();
2479
+ }
2480
+ });
2481
+
2482
+ /// Re-register state-gated commands when the authority transitions
2483
+ /// (local ↔ container). Without this, after `setAuthority` lands a
2484
+ /// container we'd still have `Attach` / `Cancel Startup` in the
2485
+ /// palette and `Detach` / `Show Logs` missing.
2486
+ ///
2487
+ /// Also runs the auto-forward port-detection sweep when entering
2488
+ /// container mode — Bug #4 (L171). Detecting an entry from
2489
+ /// `forwardPorts` that's actually bound (host-side) and emitting
2490
+ /// the spec'd `onAutoForward: notify` toast.
2491
+
2492
+
2493
+ /// Set of `port/protocol` keys we've already fired the
2494
+ /// `onAutoForward: notify` toast for in the current attach
2495
+ /// session. Cleared on detach so a re-attach re-notifies.
2496
+ const notifiedPorts = new Set<string>();
2497
+
2498
+ /// Bug #4 (L171): emit the spec'd `onAutoForward: notify` toast
2499
+ /// for ports that are both declared in `forwardPorts` AND
2500
+ /// actually bound on the host (visible in `docker port <id>`).
2501
+ ///
2502
+ /// Scoped fix: only the notification half of the spec. Actually
2503
+ /// publishing ports that aren't already mapped is a separate
2504
+ /// effort — it requires either a host-side userspace forwarder
2505
+ /// (the VS Code approach) or `appPort`/runArgs glue when starting
2506
+ /// the container, both of which are larger than this commit.
2507
+ async function runAutoForwardSweep(): Promise<void> {
2508
+ if (!config?.forwardPorts || config.forwardPorts.length === 0) return;
2509
+ const rows = await gatherForwardedPortRows();
2510
+ for (const row of rows) {
2511
+ if (row.source !== "configured") continue;
2512
+ if (!row.binding) continue;
2513
+ const attrs = config.portsAttributes?.[row.port];
2514
+ if (attrs?.onAutoForward !== "notify") continue;
2515
+ const key = `${row.port}/${row.protocol.toLowerCase()}`;
2516
+ if (notifiedPorts.has(key)) continue;
2517
+ notifiedPorts.add(key);
2518
+ const labelSuffix = attrs.label ? ` (${attrs.label})` : "";
2519
+ editor.setStatus(
2520
+ editor.t("status.port_forwarded", {
2521
+ port: row.port,
2522
+ label: labelSuffix,
2523
+ }),
2524
+ );
2525
+ }
2526
+ }
2527
+
2528
+ // =============================================================================
2529
+ // Command Registration
2530
+ // =============================================================================
2531
+
2532
+ /// State-gated commands that get re-evaluated on every authority
2533
+ /// transition. Listed in one place so `registerCommands` and the
2534
+ /// `authority_changed` cleanup path stay in sync.
2535
+ ///
2536
+ /// `show_forwarded_ports_panel` stays available in BOTH modes —
2537
+ /// the panel renders configured `forwardPorts` even when no
2538
+ /// container is up, which is useful for previewing a config
2539
+ /// (and one of the tests exercises that exact "configured only"
2540
+ /// branch).
2541
+ const ATTACHED_ONLY_COMMANDS = ["%cmd.detach", "%cmd.show_logs"];
2542
+ const DETACHED_ONLY_COMMANDS = ["%cmd.attach", "%cmd.cancel_attach"];
2543
+
2544
+ /// Bug #3 (L170): when `devcontainer.json` exists but fails to
2545
+ /// parse, register *only* a tiny recovery set so the user has an
2546
+ /// in-editor path to fix the JSON. Without this, the plugin's
2547
+ /// init path used to register zero commands and the user lost
2548
+ /// the entire `Dev Container:` family until restarting the
2549
+ /// editor.
2550
+ function registerRecoveryCommands(): void {
2551
+ // Drop full-mode entries in case we're transitioning from a
2552
+ // working config to a broken one (rebuild → restart → reparse
2553
+ // fails). `unregisterCommand` is a no-op when the name isn't
2554
+ // registered.
2555
+ for (const name of ATTACHED_ONLY_COMMANDS) editor.unregisterCommand(name);
2556
+ for (const name of DETACHED_ONLY_COMMANDS) editor.unregisterCommand(name);
2557
+ for (const name of [
2558
+ "%cmd.show_info",
2559
+ "%cmd.show_features",
2560
+ "%cmd.show_ports",
2561
+ "%cmd.rebuild",
2562
+ "%cmd.run_lifecycle",
2563
+ ]) {
2564
+ editor.unregisterCommand(name);
2565
+ }
2566
+ // `Open Config` is the recovery escape hatch — we set
2567
+ // `configPath` even on parse failure so this opens the broken
2568
+ // file in the editor for the user to repair.
2569
+ editor.registerCommand(
2570
+ "%cmd.open_config",
2571
+ "%cmd.open_config_desc",
2572
+ "devcontainer_open_config",
2573
+ null,
2574
+ );
2575
+ // `Show Build Logs` stays available so the user can read the
2576
+ // last rebuild output (likely shows the validation error from
2577
+ // the CLI / docker layer too).
2578
+ editor.registerCommand(
2579
+ "%cmd.show_build_logs",
2580
+ "%cmd.show_build_logs_desc",
2581
+ "devcontainer_show_build_logs",
2582
+ null,
2583
+ );
2584
+ }
2585
+
2586
+ function registerCommands(): void {
2587
+ // Commands that are state-relevant in BOTH local and container
2588
+ // modes (`Show Info`, `Open Config`, etc.) get registered
2589
+ // unconditionally. Commands that are only meaningful in one
2590
+ // mode are gated below — `attach` / `cancel_attach` only when
2591
+ // not already attached, `detach` / `show_forwarded_ports_panel` /
2592
+ // `show_logs` only when attached. The plugin reloads after
2593
+ // `setAuthority` AND we listen for `authority_changed` so this
2594
+ // function runs on every transition.
2595
+ const attached = editor.getAuthorityLabel().startsWith("Container:");
2596
+ // Drop any stale state-gated registrations from the previous
2597
+ // mode before re-registering. `editor.unregisterCommand` is a
2598
+ // no-op when the name isn't registered, so this is safe to call
2599
+ // even on first init.
2600
+ for (const name of ATTACHED_ONLY_COMMANDS) editor.unregisterCommand(name);
2601
+ for (const name of DETACHED_ONLY_COMMANDS) editor.unregisterCommand(name);
2602
+ editor.registerCommand(
2603
+ "%cmd.show_info",
2604
+ "%cmd.show_info_desc",
2605
+ "devcontainer_show_info",
2606
+ null,
2607
+ );
2608
+ editor.registerCommand(
2609
+ "%cmd.open_config",
2610
+ "%cmd.open_config_desc",
2611
+ "devcontainer_open_config",
2612
+ null,
2613
+ );
2614
+ editor.registerCommand(
2615
+ "%cmd.show_features",
2616
+ "%cmd.show_features_desc",
2617
+ "devcontainer_show_features",
2618
+ null,
2619
+ );
2620
+ editor.registerCommand(
2621
+ "%cmd.show_ports",
2622
+ "%cmd.show_ports_desc",
2623
+ "devcontainer_show_ports",
2624
+ null,
2625
+ );
2626
+ editor.registerCommand(
2627
+ "%cmd.rebuild",
2628
+ "%cmd.rebuild_desc",
2629
+ "devcontainer_rebuild",
2630
+ null,
2631
+ );
2632
+ editor.registerCommand(
2633
+ "%cmd.show_build_logs",
2634
+ "%cmd.show_build_logs_desc",
2635
+ "devcontainer_show_build_logs",
2636
+ null,
2637
+ );
2638
+ // `run_lifecycle` works in both modes — `initializeCommand` is
2639
+ // host-side per spec, the rest are container-side. The picker
2640
+ // itself filters which entries are runnable.
2641
+ editor.registerCommand(
2642
+ "%cmd.run_lifecycle",
2643
+ "%cmd.run_lifecycle_desc",
2644
+ "devcontainer_run_lifecycle",
2645
+ null,
2646
+ );
2647
+ // `show_forwarded_ports_panel` works in both modes too — the
2648
+ // panel renders configured `forwardPorts` even with no
2649
+ // container up. (The "configured only" branch is what the
2650
+ // panel's regression test exercises.)
2651
+ editor.registerCommand(
2652
+ "%cmd.show_forwarded_ports_panel",
2653
+ "%cmd.show_forwarded_ports_panel_desc",
2654
+ "devcontainer_show_forwarded_ports_panel",
2655
+ null,
2656
+ );
2657
+ if (attached) {
2658
+ editor.registerCommand(
2659
+ "%cmd.detach",
2660
+ "%cmd.detach_desc",
2661
+ "devcontainer_detach",
2662
+ null,
2663
+ );
2664
+ editor.registerCommand(
2665
+ "%cmd.show_logs",
2666
+ "%cmd.show_logs_desc",
2667
+ "devcontainer_show_logs",
2668
+ null,
2669
+ );
2670
+ } else {
2671
+ editor.registerCommand(
2672
+ "%cmd.attach",
2673
+ "%cmd.attach_desc",
2674
+ "devcontainer_attach",
2675
+ null,
2676
+ );
2677
+ editor.registerCommand(
2678
+ "%cmd.cancel_attach",
2679
+ "%cmd.cancel_attach_desc",
2680
+ "devcontainer_cancel_attach",
2681
+ null,
2682
+ );
2683
+ }
2684
+ }
2685
+
2686
+ // =============================================================================
2687
+ // Initialization
2688
+ // =============================================================================
2689
+
2690
+ // The scaffold command is the only palette entry that makes sense
2691
+ // without a detected config — it's how the user creates one. Register
2692
+ // unconditionally so "Dev Container: Create Config" is reachable from
2693
+ // a cold workspace.
2694
+ editor.registerCommand(
2695
+ "%cmd.scaffold_config",
2696
+ "%cmd.scaffold_config_desc",
2697
+ "devcontainer_scaffold_config",
2698
+ null,
2699
+ );
2700
+
2701
+ if (findConfig()) {
2702
+ registerCommands();
2703
+
2704
+ const name = config!.name ?? "unnamed";
2705
+ const image = getImageSummary();
2706
+ const featureCount = config!.features ? Object.keys(config!.features).length : 0;
2707
+ const portCount = config!.forwardPorts?.length ?? 0;
2708
+
2709
+ editor.setStatus(
2710
+ editor.t("status.detected", {
2711
+ name,
2712
+ image,
2713
+ features: String(featureCount),
2714
+ ports: String(portCount),
2715
+ }),
2716
+ );
2717
+
2718
+ editor.debug("Dev Container plugin initialized: " + name);
2719
+
2720
+ // Decide whether to surface the attach prompt AFTER main.rs installs
2721
+ // the boot authority. When the plugin's top-level body runs, the
2722
+ // editor is still being constructed and `authority.display_label` is
2723
+ // whatever the default Authority carried during Editor construction —
2724
+ // which is empty even on the post-attach restart, because the real
2725
+ // container authority is only installed via `set_boot_authority`
2726
+ // (called right before `plugins_loaded` fires). Deferring to this
2727
+ // hook means `getAuthorityLabel()` reads the freshly-refreshed
2728
+ // snapshot and we don't re-prompt a user who already attached.
2729
+ function devcontainer_maybe_show_attach_prompt(): void {
2730
+ const authorityLabel = editor.getAuthorityLabel();
2731
+ const alreadyAttached = authorityLabel.length > 0;
2732
+
2733
+ // Post-restart recovery: clear or surface a FailedAttach for
2734
+ // attempts that round-tripped through setAuthority without
2735
+ // landing a container. Stale breadcrumbs (> 30 min) are
2736
+ // quietly dropped so an old attempt can't poison a fresh
2737
+ // session years later.
2738
+ const attemptMs = readAttachAttemptMs();
2739
+ if (attemptMs !== null) {
2740
+ const ageMs = Date.now() - attemptMs;
2741
+ const MAX_AGE_MS = 30 * 60 * 1000;
2742
+ if (ageMs > MAX_AGE_MS) {
2743
+ clearAttachAttempt();
2744
+ } else if (alreadyAttached) {
2745
+ // Matching container authority came up — success path.
2746
+ clearAttachAttempt();
2747
+ } else {
2748
+ // No container landed but we just tried. Surface it with the
2749
+ // same proactive popup as an in-flight failure so users see
2750
+ // Retry / Reopen Locally without having to click the
2751
+ // indicator.
2752
+ enterFailedAttach(editor.t("indicator.error_restart_recovery"));
2753
+ clearAttachAttempt();
2754
+ // Do not also show the attach prompt — the failed-attach
2755
+ // popup is the right next surface; stacking a second popup
2756
+ // on top would bury it.
2757
+ return;
2758
+ }
2759
+ }
2760
+
2761
+ if (alreadyAttached) {
2762
+ editor.debug(
2763
+ "Dev Container plugin: authority '" + authorityLabel + "' already installed, skipping attach prompt",
2764
+ );
2765
+ return;
2766
+ }
2767
+ // Persistent dismissal (`Ignore (always in this directory)`)
2768
+ // OR a successful prior attach — both are recorded in plugin
2769
+ // global state and survive editor restarts. Skip the popup.
2770
+ const previousDecision = readAttachDecision();
2771
+ if (previousDecision !== null) return;
2772
+ // Session-only dismissal (`Ignore (once)`): in-memory flag,
2773
+ // cleared on plugin reload so the *next* editor restart
2774
+ // re-asks in this workspace.
2775
+ if (attachDismissedThisSession) return;
2776
+ showAttachPrompt();
2777
+ }
2778
+ editor.on("plugins_loaded", devcontainer_maybe_show_attach_prompt);
2779
+ } else {
2780
+ // Bug #2 + #3: a `devcontainer.json` that exists but fails to
2781
+ // parse used to fail silently AND drop every `Dev Container:`
2782
+ // command from the palette, leaving no in-editor recovery path
2783
+ // (the user had to restart the editor). Now: surface the parse
2784
+ // error in the status bar AND register a small recovery set
2785
+ // (Open Config + Show Build Logs) so the user can navigate to
2786
+ // the broken file and fix it.
2787
+ if (lastParseError) {
2788
+ showParseErrorIfAny();
2789
+ registerRecoveryCommands();
2790
+ } else {
2791
+ editor.debug("Dev Container plugin: no devcontainer.json found");
2792
+ }
2793
+ }