@fresh-editor/fresh-editor 0.2.25 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2066 @@
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
+ containerUser?: string;
37
+ remoteUser?: string;
38
+ mounts?: (string | MountConfig)[];
39
+ initializeCommand?: LifecycleCommand;
40
+ onCreateCommand?: LifecycleCommand;
41
+ updateContentCommand?: LifecycleCommand;
42
+ postCreateCommand?: LifecycleCommand;
43
+ postStartCommand?: LifecycleCommand;
44
+ postAttachCommand?: LifecycleCommand;
45
+ customizations?: Record<string, unknown>;
46
+ runArgs?: string[];
47
+ workspaceFolder?: string;
48
+ workspaceMount?: string;
49
+ shutdownAction?: string;
50
+ overrideCommand?: boolean;
51
+ init?: boolean;
52
+ privileged?: boolean;
53
+ capAdd?: string[];
54
+ securityOpt?: string[];
55
+ hostRequirements?: {
56
+ cpus?: number;
57
+ memory?: string;
58
+ storage?: string;
59
+ gpu?: boolean | string | { cores?: number; memory?: string };
60
+ };
61
+ }
62
+
63
+ type LifecycleCommand = string | string[] | Record<string, string | string[]>;
64
+
65
+ interface PortAttributes {
66
+ label?: string;
67
+ protocol?: string;
68
+ onAutoForward?: string;
69
+ requireLocalPort?: boolean;
70
+ elevateIfNeeded?: boolean;
71
+ }
72
+
73
+ interface MountConfig {
74
+ type?: string;
75
+ source?: string;
76
+ target?: string;
77
+ }
78
+
79
+ // =============================================================================
80
+ // State
81
+ // =============================================================================
82
+
83
+ let config: DevContainerConfig | null = null;
84
+ let configPath: string | null = null;
85
+ let infoPanelBufferId: number | null = null;
86
+ let infoPanelSplitId: number | null = null;
87
+ let infoPanelOpen = false;
88
+ let cachedContent = "";
89
+
90
+ // The in-flight `devcontainer up` handle (set before we await, cleared
91
+ // on exit). `devcontainer_cancel_attach` forwards `.kill()` to this.
92
+ // null when no attach is running.
93
+ let attachInFlight: ProcessHandle<SpawnResult> | null = null;
94
+
95
+ // Set by `devcontainer_cancel_attach` right before it kills the
96
+ // in-flight handle; read by `runDevcontainerUp` so the non-zero exit
97
+ // coming out of the kill doesn't also trigger a FailedAttach — the
98
+ // cancel already set the indicator back to Local.
99
+ let attachCancelled = false;
100
+
101
+ // Focus state for info panel buttons (Tab navigation like pkg.ts)
102
+ type InfoFocusTarget = { type: "button"; index: number };
103
+
104
+ interface InfoButton {
105
+ id: string;
106
+ label: string;
107
+ command: string;
108
+ }
109
+
110
+ const infoButtons: InfoButton[] = [
111
+ { id: "run", label: "Run Lifecycle", command: "devcontainer_run_lifecycle" },
112
+ { id: "open", label: "Open Config", command: "devcontainer_open_config" },
113
+ { id: "rebuild", label: "Rebuild", command: "devcontainer_rebuild" },
114
+ { id: "close", label: "Close", command: "devcontainer_close_info" },
115
+ ];
116
+
117
+ let infoFocus: InfoFocusTarget = { type: "button", index: 0 };
118
+
119
+ // =============================================================================
120
+ // Colors
121
+ // =============================================================================
122
+
123
+ const colors = {
124
+ heading: [255, 200, 100] as [number, number, number],
125
+ key: [100, 200, 255] as [number, number, number],
126
+ value: [200, 200, 200] as [number, number, number],
127
+ feature: [150, 255, 150] as [number, number, number],
128
+ port: [255, 180, 100] as [number, number, number],
129
+ footer: [120, 120, 120] as [number, number, number],
130
+ button: [180, 180, 190] as [number, number, number],
131
+ buttonFocused: [255, 255, 255] as [number, number, number],
132
+ buttonFocusedBg: [60, 110, 180] as [number, number, number],
133
+ };
134
+
135
+ // =============================================================================
136
+ // Config Discovery
137
+ // =============================================================================
138
+
139
+ function findConfig(): boolean {
140
+ const cwd = editor.getCwd();
141
+
142
+ // Priority 1: .devcontainer/devcontainer.json
143
+ const primary = editor.pathJoin(cwd, ".devcontainer", "devcontainer.json");
144
+ const primaryContent = editor.readFile(primary);
145
+ if (primaryContent !== null) {
146
+ try {
147
+ config = editor.parseJsonc(primaryContent) as DevContainerConfig;
148
+ configPath = primary;
149
+ return true;
150
+ } catch {
151
+ editor.debug("devcontainer: failed to parse " + primary);
152
+ }
153
+ }
154
+
155
+ // Priority 2: .devcontainer.json
156
+ const secondary = editor.pathJoin(cwd, ".devcontainer.json");
157
+ const secondaryContent = editor.readFile(secondary);
158
+ if (secondaryContent !== null) {
159
+ try {
160
+ config = editor.parseJsonc(secondaryContent) as DevContainerConfig;
161
+ configPath = secondary;
162
+ return true;
163
+ } catch {
164
+ editor.debug("devcontainer: failed to parse " + secondary);
165
+ }
166
+ }
167
+
168
+ // Priority 3: .devcontainer/<subfolder>/devcontainer.json
169
+ const dcDir = editor.pathJoin(cwd, ".devcontainer");
170
+ if (editor.fileExists(dcDir)) {
171
+ const entries = editor.readDir(dcDir);
172
+ for (const entry of entries) {
173
+ if (entry.is_dir) {
174
+ const subConfig = editor.pathJoin(dcDir, entry.name, "devcontainer.json");
175
+ const subContent = editor.readFile(subConfig);
176
+ if (subContent !== null) {
177
+ try {
178
+ config = editor.parseJsonc(subContent) as DevContainerConfig;
179
+ configPath = subConfig;
180
+ return true;
181
+ } catch {
182
+ editor.debug("devcontainer: failed to parse " + subConfig);
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ return false;
190
+ }
191
+
192
+ // =============================================================================
193
+ // Formatting Helpers
194
+ // =============================================================================
195
+
196
+ function formatLifecycleCommand(cmd: LifecycleCommand): string {
197
+ if (typeof cmd === "string") return cmd;
198
+ if (Array.isArray(cmd)) return cmd.join(" ");
199
+ return Object.entries(cmd)
200
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(" ") : v}`)
201
+ .join("; ");
202
+ }
203
+
204
+ function formatMount(mount: string | MountConfig): string {
205
+ if (typeof mount === "string") return mount;
206
+ const parts: string[] = [];
207
+ if (mount.source) parts.push(mount.source);
208
+ parts.push("->");
209
+ if (mount.target) parts.push(mount.target);
210
+ if (mount.type) parts.push(`(${mount.type})`);
211
+ return parts.join(" ");
212
+ }
213
+
214
+ function getImageSummary(): string {
215
+ if (!config) return "unknown";
216
+ if (config.image) return config.image;
217
+ if (config.build?.dockerfile) return "Dockerfile: " + config.build.dockerfile;
218
+ if (config.dockerComposeFile) return "Compose";
219
+ return "unknown";
220
+ }
221
+
222
+ // =============================================================================
223
+ // Info Panel
224
+ // =============================================================================
225
+
226
+ function buildInfoEntries(): TextPropertyEntry[] {
227
+ if (!config) return [];
228
+
229
+ const entries: TextPropertyEntry[] = [];
230
+
231
+ // Header
232
+ const name = config.name ?? "unnamed";
233
+ entries.push({
234
+ text: editor.t("panel.header", { name }) + "\n",
235
+ properties: { type: "heading" },
236
+ });
237
+ entries.push({ text: "\n", properties: { type: "blank" } });
238
+
239
+ // Image / Build / Compose
240
+ if (config.image) {
241
+ entries.push({ text: editor.t("panel.section_image") + "\n", properties: { type: "heading" } });
242
+ entries.push({ text: " " + config.image + "\n", properties: { type: "value" } });
243
+ entries.push({ text: "\n", properties: { type: "blank" } });
244
+ } else if (config.build?.dockerfile) {
245
+ entries.push({ text: editor.t("panel.section_build") + "\n", properties: { type: "heading" } });
246
+ entries.push({ text: " dockerfile: " + config.build.dockerfile + "\n", properties: { type: "value" } });
247
+ if (config.build.context) {
248
+ entries.push({ text: " context: " + config.build.context + "\n", properties: { type: "value" } });
249
+ }
250
+ if (config.build.target) {
251
+ entries.push({ text: " target: " + config.build.target + "\n", properties: { type: "value" } });
252
+ }
253
+ entries.push({ text: "\n", properties: { type: "blank" } });
254
+ } else if (config.dockerComposeFile) {
255
+ entries.push({ text: editor.t("panel.section_compose") + "\n", properties: { type: "heading" } });
256
+ const files = Array.isArray(config.dockerComposeFile)
257
+ ? config.dockerComposeFile.join(", ")
258
+ : config.dockerComposeFile;
259
+ entries.push({ text: " files: " + files + "\n", properties: { type: "value" } });
260
+ if (config.service) {
261
+ entries.push({ text: " service: " + config.service + "\n", properties: { type: "value" } });
262
+ }
263
+ entries.push({ text: "\n", properties: { type: "blank" } });
264
+ }
265
+
266
+ // Features
267
+ if (config.features && Object.keys(config.features).length > 0) {
268
+ entries.push({ text: editor.t("panel.section_features") + "\n", properties: { type: "heading" } });
269
+ for (const [id, opts] of Object.entries(config.features)) {
270
+ entries.push({ text: " + " + id + "\n", properties: { type: "feature", id } });
271
+ if (typeof opts === "object" && opts !== null) {
272
+ const optStr = Object.entries(opts as Record<string, unknown>)
273
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
274
+ .join(", ");
275
+ if (optStr) {
276
+ entries.push({ text: " " + optStr + "\n", properties: { type: "feature-opts" } });
277
+ }
278
+ }
279
+ }
280
+ entries.push({ text: "\n", properties: { type: "blank" } });
281
+ }
282
+
283
+ // Ports
284
+ if (config.forwardPorts && config.forwardPorts.length > 0) {
285
+ entries.push({ text: editor.t("panel.section_ports") + "\n", properties: { type: "heading" } });
286
+ for (const port of config.forwardPorts) {
287
+ const attrs = config.portsAttributes?.[String(port)];
288
+ const proto = attrs?.protocol ?? "tcp";
289
+ let detail = ` ${port} -> ${proto}`;
290
+ if (attrs?.label) detail += ` (${attrs.label})`;
291
+ if (attrs?.onAutoForward) detail += ` [${attrs.onAutoForward}]`;
292
+ entries.push({ text: detail + "\n", properties: { type: "port", port: String(port) } });
293
+ }
294
+ entries.push({ text: "\n", properties: { type: "blank" } });
295
+ }
296
+
297
+ // Environment
298
+ const allEnv: Record<string, string> = {};
299
+ if (config.containerEnv) Object.assign(allEnv, config.containerEnv);
300
+ if (config.remoteEnv) Object.assign(allEnv, config.remoteEnv);
301
+ const envKeys = Object.keys(allEnv);
302
+ if (envKeys.length > 0) {
303
+ entries.push({ text: editor.t("panel.section_env") + "\n", properties: { type: "heading" } });
304
+ for (const k of envKeys) {
305
+ entries.push({ text: ` ${k} = ${allEnv[k]}\n`, properties: { type: "env" } });
306
+ }
307
+ entries.push({ text: "\n", properties: { type: "blank" } });
308
+ }
309
+
310
+ // Mounts
311
+ if (config.mounts && config.mounts.length > 0) {
312
+ entries.push({ text: editor.t("panel.section_mounts") + "\n", properties: { type: "heading" } });
313
+ for (const mount of config.mounts) {
314
+ entries.push({ text: " " + formatMount(mount) + "\n", properties: { type: "mount" } });
315
+ }
316
+ entries.push({ text: "\n", properties: { type: "blank" } });
317
+ }
318
+
319
+ // Users
320
+ if (config.containerUser || config.remoteUser) {
321
+ entries.push({ text: editor.t("panel.section_users") + "\n", properties: { type: "heading" } });
322
+ if (config.containerUser) {
323
+ entries.push({ text: " containerUser: " + config.containerUser + "\n", properties: { type: "value" } });
324
+ }
325
+ if (config.remoteUser) {
326
+ entries.push({ text: " remoteUser: " + config.remoteUser + "\n", properties: { type: "value" } });
327
+ }
328
+ entries.push({ text: "\n", properties: { type: "blank" } });
329
+ }
330
+
331
+ // Lifecycle Commands
332
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
333
+ ["initializeCommand", config.initializeCommand],
334
+ ["onCreateCommand", config.onCreateCommand],
335
+ ["updateContentCommand", config.updateContentCommand],
336
+ ["postCreateCommand", config.postCreateCommand],
337
+ ["postStartCommand", config.postStartCommand],
338
+ ["postAttachCommand", config.postAttachCommand],
339
+ ];
340
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
341
+ if (defined.length > 0) {
342
+ entries.push({ text: editor.t("panel.section_lifecycle") + "\n", properties: { type: "heading" } });
343
+ for (const [cmdName, cmd] of defined) {
344
+ entries.push({
345
+ text: ` ${cmdName}: ${formatLifecycleCommand(cmd!)}\n`,
346
+ properties: { type: "lifecycle", command: cmdName },
347
+ });
348
+ }
349
+ entries.push({ text: "\n", properties: { type: "blank" } });
350
+ }
351
+
352
+ // Host Requirements
353
+ if (config.hostRequirements) {
354
+ const hr = config.hostRequirements;
355
+ entries.push({ text: editor.t("panel.section_host_req") + "\n", properties: { type: "heading" } });
356
+ if (hr.cpus) entries.push({ text: ` cpus: ${hr.cpus}\n`, properties: { type: "value" } });
357
+ if (hr.memory) entries.push({ text: ` memory: ${hr.memory}\n`, properties: { type: "value" } });
358
+ if (hr.storage) entries.push({ text: ` storage: ${hr.storage}\n`, properties: { type: "value" } });
359
+ if (hr.gpu) entries.push({ text: ` gpu: ${JSON.stringify(hr.gpu)}\n`, properties: { type: "value" } });
360
+ entries.push({ text: "\n", properties: { type: "blank" } });
361
+ }
362
+
363
+ // Separator before buttons
364
+ entries.push({
365
+ text: "─".repeat(40) + "\n",
366
+ properties: { type: "separator" },
367
+ });
368
+
369
+ // Action buttons row (Tab-navigable, like pkg.ts)
370
+ entries.push({ text: " ", properties: { type: "spacer" } });
371
+ for (let i = 0; i < infoButtons.length; i++) {
372
+ const btn = infoButtons[i];
373
+ const focused = infoFocus.index === i;
374
+ const leftBracket = focused ? "[" : " ";
375
+ const rightBracket = focused ? "]" : " ";
376
+ entries.push({
377
+ text: `${leftBracket} ${btn.label} ${rightBracket}`,
378
+ properties: { type: "button", focused, btnIndex: i },
379
+ });
380
+ if (i < infoButtons.length - 1) {
381
+ entries.push({ text: " ", properties: { type: "spacer" } });
382
+ }
383
+ }
384
+ entries.push({ text: "\n", properties: { type: "newline" } });
385
+
386
+ // Help line
387
+ entries.push({
388
+ text: editor.t("panel.footer") + "\n",
389
+ properties: { type: "footer" },
390
+ });
391
+
392
+ return entries;
393
+ }
394
+
395
+ function entriesToContent(entries: TextPropertyEntry[]): string {
396
+ return entries.map((e) => e.text).join("");
397
+ }
398
+
399
+ function applyInfoHighlighting(): void {
400
+ if (infoPanelBufferId === null) return;
401
+ const bufferId = infoPanelBufferId;
402
+
403
+ editor.clearNamespace(bufferId, "devcontainer");
404
+
405
+ const content = cachedContent;
406
+ if (!content) return;
407
+
408
+ const lines = content.split("\n");
409
+ let byteOffset = 0;
410
+
411
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
412
+ const line = lines[lineIdx];
413
+ const lineStart = byteOffset;
414
+ const lineByteLen = editor.utf8ByteLength(line);
415
+ const lineEnd = lineStart + lineByteLen;
416
+
417
+ // Heading lines (sections)
418
+ if (
419
+ line.startsWith("Dev Container:") ||
420
+ line === editor.t("panel.section_image") ||
421
+ line === editor.t("panel.section_build") ||
422
+ line === editor.t("panel.section_compose") ||
423
+ line === editor.t("panel.section_features") ||
424
+ line === editor.t("panel.section_ports") ||
425
+ line === editor.t("panel.section_env") ||
426
+ line === editor.t("panel.section_mounts") ||
427
+ line === editor.t("panel.section_users") ||
428
+ line === editor.t("panel.section_lifecycle") ||
429
+ line === editor.t("panel.section_host_req")
430
+ ) {
431
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
432
+ fg: colors.heading,
433
+ bold: true,
434
+ });
435
+ }
436
+ // Feature lines
437
+ else if (line.startsWith(" + ")) {
438
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
439
+ fg: colors.feature,
440
+ });
441
+ }
442
+ // Port lines
443
+ else if (line.match(/^\s+\d+\s*->/)) {
444
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
445
+ fg: colors.port,
446
+ });
447
+ }
448
+ // Key = value lines (env vars)
449
+ else if (line.match(/^\s+\w+\s*=/)) {
450
+ const eqIdx = line.indexOf("=");
451
+ if (eqIdx > 0) {
452
+ const keyEnd = lineStart + editor.utf8ByteLength(line.substring(0, eqIdx));
453
+ editor.addOverlay(bufferId, "devcontainer", lineStart, keyEnd, {
454
+ fg: colors.key,
455
+ });
456
+ }
457
+ }
458
+ // Separator
459
+ else if (line.match(/^─+$/)) {
460
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
461
+ fg: colors.footer,
462
+ });
463
+ }
464
+ // Footer help line
465
+ else if (line === editor.t("panel.footer")) {
466
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
467
+ fg: colors.footer,
468
+ italic: true,
469
+ });
470
+ }
471
+
472
+ byteOffset += lineByteLen + 1; // +1 for newline
473
+ }
474
+
475
+ // Apply button highlighting using entry-based scanning
476
+ // We need to walk entries to find button text positions in the content
477
+ applyButtonHighlighting();
478
+ }
479
+
480
+ function applyButtonHighlighting(): void {
481
+ if (infoPanelBufferId === null) return;
482
+ const bufferId = infoPanelBufferId;
483
+
484
+ // Re-scan entries to find button positions
485
+ const entries = buildInfoEntries();
486
+ let byteOffset = 0;
487
+
488
+ for (const entry of entries) {
489
+ const props = entry.properties as Record<string, unknown>;
490
+ const len = editor.utf8ByteLength(entry.text);
491
+
492
+ if (props.type === "button") {
493
+ const focused = props.focused as boolean;
494
+ if (focused) {
495
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
496
+ fg: colors.buttonFocused,
497
+ bg: colors.buttonFocusedBg,
498
+ bold: true,
499
+ });
500
+ } else {
501
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
502
+ fg: colors.button,
503
+ });
504
+ }
505
+ }
506
+
507
+ byteOffset += len;
508
+ }
509
+ }
510
+
511
+ function updateInfoPanel(): void {
512
+ if (infoPanelBufferId === null) return;
513
+ const entries = buildInfoEntries();
514
+ cachedContent = entriesToContent(entries);
515
+ editor.setVirtualBufferContent(infoPanelBufferId, entries);
516
+ applyInfoHighlighting();
517
+ }
518
+
519
+ // =============================================================================
520
+ // Mode Definition
521
+ // =============================================================================
522
+
523
+ editor.defineMode(
524
+ "devcontainer-info",
525
+ [
526
+ ["Tab", "devcontainer_next_button"],
527
+ ["S-Tab", "devcontainer_prev_button"],
528
+ ["Return", "devcontainer_activate_button"],
529
+ ["M-r", "devcontainer_run_lifecycle"],
530
+ ["M-o", "devcontainer_open_config"],
531
+ ["M-b", "devcontainer_rebuild"],
532
+ ["q", "devcontainer_close_info"],
533
+ ["Escape", "devcontainer_close_info"],
534
+ ],
535
+ true, // read-only
536
+ false, // allow_text_input
537
+ true, // inherit Normal-context bindings so arrow keys / page nav still work
538
+ );
539
+
540
+ // =============================================================================
541
+ // Info Panel Button Navigation
542
+ // =============================================================================
543
+
544
+ // Plugin code runs inside an IIFE, so `function foo() {}` declarations don't
545
+ // land on globalThis on their own. Register each handler explicitly so it can
546
+ // be referenced by string name from defineMode bindings, registerCommand, and
547
+ // event handlers (see also pkg.ts).
548
+
549
+ function devcontainer_next_button(): void {
550
+ if (!infoPanelOpen) return;
551
+ infoFocus = { type: "button", index: (infoFocus.index + 1) % infoButtons.length };
552
+ updateInfoPanel();
553
+ }
554
+ registerHandler("devcontainer_next_button", devcontainer_next_button);
555
+
556
+ function devcontainer_prev_button(): void {
557
+ if (!infoPanelOpen) return;
558
+ infoFocus = { type: "button", index: (infoFocus.index - 1 + infoButtons.length) % infoButtons.length };
559
+ updateInfoPanel();
560
+ }
561
+ registerHandler("devcontainer_prev_button", devcontainer_prev_button);
562
+
563
+ function devcontainer_activate_button(): void {
564
+ if (!infoPanelOpen) return;
565
+ const btn = infoButtons[infoFocus.index];
566
+ if (!btn) return;
567
+ const handler = (globalThis as Record<string, unknown>)[btn.command];
568
+ if (typeof handler === "function") {
569
+ (handler as () => void)();
570
+ }
571
+ }
572
+ registerHandler("devcontainer_activate_button", devcontainer_activate_button);
573
+
574
+ // =============================================================================
575
+ // Commands
576
+ // =============================================================================
577
+
578
+ async function devcontainer_show_info(): Promise<void> {
579
+ if (!config) {
580
+ editor.setStatus(editor.t("status.no_config"));
581
+ return;
582
+ }
583
+
584
+ if (infoPanelOpen && infoPanelBufferId !== null) {
585
+ // Already open - refresh content
586
+ updateInfoPanel();
587
+ return;
588
+ }
589
+
590
+ infoFocus = { type: "button", index: 0 };
591
+ const entries = buildInfoEntries();
592
+ cachedContent = entriesToContent(entries);
593
+
594
+ const result = await editor.createVirtualBufferInSplit({
595
+ name: "*Dev Container*",
596
+ mode: "devcontainer-info",
597
+ readOnly: true,
598
+ showLineNumbers: false,
599
+ showCursors: true,
600
+ editingDisabled: true,
601
+ lineWrap: true,
602
+ ratio: 0.4,
603
+ direction: "horizontal",
604
+ entries: entries,
605
+ });
606
+
607
+ if (result !== null) {
608
+ infoPanelOpen = true;
609
+ infoPanelBufferId = result.bufferId;
610
+ infoPanelSplitId = result.splitId;
611
+ applyInfoHighlighting();
612
+ editor.setStatus(editor.t("status.panel_opened"));
613
+ }
614
+ }
615
+ registerHandler("devcontainer_show_info", devcontainer_show_info);
616
+
617
+ function devcontainer_close_info(): void {
618
+ if (!infoPanelOpen) return;
619
+
620
+ if (infoPanelSplitId !== null) {
621
+ editor.closeSplit(infoPanelSplitId);
622
+ }
623
+ if (infoPanelBufferId !== null) {
624
+ editor.closeBuffer(infoPanelBufferId);
625
+ }
626
+
627
+ infoPanelOpen = false;
628
+ infoPanelBufferId = null;
629
+ infoPanelSplitId = null;
630
+ editor.setStatus(editor.t("status.panel_closed"));
631
+ }
632
+ registerHandler("devcontainer_close_info", devcontainer_close_info);
633
+
634
+ function devcontainer_open_config(): void {
635
+ if (configPath) {
636
+ editor.openFile(configPath, null, null);
637
+ } else {
638
+ editor.setStatus(editor.t("status.no_config"));
639
+ }
640
+ }
641
+ registerHandler("devcontainer_open_config", devcontainer_open_config);
642
+
643
+ function devcontainer_run_lifecycle(): void {
644
+ if (!config) {
645
+ editor.setStatus(editor.t("status.no_config"));
646
+ return;
647
+ }
648
+
649
+ // `initializeCommand` is the host-side prologue per the dev-container
650
+ // spec — surface it in the picker so users can re-run it on demand.
651
+ // The automatic attach flow runs it separately (see runDevcontainerUp)
652
+ // before `devcontainer up`, so the CLI-driven hooks that follow don't
653
+ // re-run it.
654
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
655
+ ["initializeCommand", config.initializeCommand],
656
+ ["onCreateCommand", config.onCreateCommand],
657
+ ["updateContentCommand", config.updateContentCommand],
658
+ ["postCreateCommand", config.postCreateCommand],
659
+ ["postStartCommand", config.postStartCommand],
660
+ ["postAttachCommand", config.postAttachCommand],
661
+ ];
662
+
663
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
664
+ if (defined.length === 0) {
665
+ editor.setStatus(editor.t("status.no_lifecycle"));
666
+ return;
667
+ }
668
+
669
+ const suggestions: PromptSuggestion[] = defined.map(([name, cmd]) => ({
670
+ text: name,
671
+ description: formatLifecycleCommand(cmd!),
672
+ value: name,
673
+ }));
674
+
675
+ editor.startPrompt(editor.t("prompt.run_lifecycle"), "devcontainer-lifecycle");
676
+ editor.setPromptSuggestions(suggestions);
677
+ }
678
+ registerHandler("devcontainer_run_lifecycle", devcontainer_run_lifecycle);
679
+
680
+ async function devcontainer_on_lifecycle_confirmed(data: {
681
+ prompt_type: string;
682
+ value: string;
683
+ }): Promise<void> {
684
+ if (data.prompt_type !== "devcontainer-lifecycle") return;
685
+
686
+ const cmdName = data.value;
687
+ if (!config || !cmdName) return;
688
+
689
+ const cmd = (config as Record<string, unknown>)[cmdName] as LifecycleCommand | undefined;
690
+ if (!cmd) return;
691
+
692
+ if (typeof cmd === "string") {
693
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
694
+ const result = await editor.spawnProcess("sh", ["-c", cmd], editor.getCwd());
695
+ if (result.exit_code === 0) {
696
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
697
+ } else {
698
+ editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
699
+ }
700
+ } else if (Array.isArray(cmd)) {
701
+ const [bin, ...args] = cmd;
702
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
703
+ const result = await editor.spawnProcess(bin, args, editor.getCwd());
704
+ if (result.exit_code === 0) {
705
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
706
+ } else {
707
+ editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
708
+ }
709
+ } else {
710
+ // Object form: run each named sub-command sequentially
711
+ for (const [label, subcmd] of Object.entries(cmd)) {
712
+ editor.setStatus(editor.t("status.running_sub", { name: cmdName, label }));
713
+ let bin: string;
714
+ let args: string[];
715
+ if (Array.isArray(subcmd)) {
716
+ [bin, ...args] = subcmd;
717
+ } else {
718
+ bin = "sh";
719
+ args = ["-c", subcmd as string];
720
+ }
721
+ const result = await editor.spawnProcess(bin, args, editor.getCwd());
722
+ if (result.exit_code !== 0) {
723
+ editor.setStatus(editor.t("status.failed_sub", { name: cmdName, label, code: String(result.exit_code) }));
724
+ return;
725
+ }
726
+ }
727
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
728
+ }
729
+ }
730
+ registerHandler("devcontainer_on_lifecycle_confirmed", devcontainer_on_lifecycle_confirmed);
731
+
732
+ function devcontainer_show_features(): void {
733
+ if (!config || !config.features || Object.keys(config.features).length === 0) {
734
+ editor.setStatus(editor.t("status.no_features"));
735
+ return;
736
+ }
737
+
738
+ const suggestions: PromptSuggestion[] = Object.entries(config.features).map(([id, opts]) => {
739
+ let desc = "";
740
+ if (typeof opts === "object" && opts !== null) {
741
+ desc = Object.entries(opts as Record<string, unknown>)
742
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
743
+ .join(", ");
744
+ } else if (typeof opts === "string") {
745
+ desc = opts;
746
+ }
747
+ return { text: id, description: desc || "(default options)" };
748
+ });
749
+
750
+ editor.startPrompt(editor.t("prompt.features"), "devcontainer-features");
751
+ editor.setPromptSuggestions(suggestions);
752
+ }
753
+ registerHandler("devcontainer_show_features", devcontainer_show_features);
754
+
755
+ /// Parse `docker port <id>` output into a map from
756
+ /// "<container-port>/<proto>" to "<host>:<host-port>".
757
+ ///
758
+ /// Each output line looks like `8080/tcp -> 0.0.0.0:49153`. Malformed
759
+ /// lines are skipped — we prefer a partial merge over bailing on
760
+ /// unknown formats from future Docker versions.
761
+ function parseDockerPortOutput(stdout: string): Record<string, string> {
762
+ const map: Record<string, string> = {};
763
+ for (const rawLine of stdout.split("\n")) {
764
+ const line = rawLine.trim();
765
+ if (!line) continue;
766
+ const arrow = line.indexOf(" -> ");
767
+ if (arrow < 0) continue;
768
+ const left = line.slice(0, arrow).trim();
769
+ const right = line.slice(arrow + 4).trim();
770
+ if (left && right) map[left] = right;
771
+ }
772
+ return map;
773
+ }
774
+
775
+ async function devcontainer_show_ports(): Promise<void> {
776
+ if (!config || !config.forwardPorts || config.forwardPorts.length === 0) {
777
+ editor.setStatus(editor.t("status.no_ports"));
778
+ return;
779
+ }
780
+
781
+ // When attached to a container, merge runtime bindings from
782
+ // `docker port <id>` into the prompt descriptions so the user sees
783
+ // which configured ports actually reached the host. Off-container
784
+ // the runtime side is unavailable; fall back to config-only.
785
+ let runtime: Record<string, string> = {};
786
+ const authorityLabel = editor.getAuthorityLabel();
787
+ const prefix = "Container:";
788
+ if (authorityLabel.startsWith(prefix)) {
789
+ const containerId = authorityLabel.slice(prefix.length);
790
+ if (containerId.length > 0) {
791
+ const which = await editor.spawnHostProcess("which", ["docker"]);
792
+ if (which.exit_code === 0) {
793
+ const res = await editor.spawnHostProcess(
794
+ "docker",
795
+ ["port", containerId],
796
+ editor.getCwd(),
797
+ );
798
+ if (res.exit_code === 0) {
799
+ runtime = parseDockerPortOutput(res.stdout);
800
+ }
801
+ }
802
+ }
803
+ }
804
+
805
+ const suggestions: PromptSuggestion[] = config.forwardPorts.map((port) => {
806
+ const attrs = config!.portsAttributes?.[String(port)];
807
+ const proto = attrs?.protocol ?? "tcp";
808
+ let desc = proto;
809
+ if (attrs?.label) desc += ` · ${attrs.label}`;
810
+ if (attrs?.onAutoForward) desc += ` (${attrs.onAutoForward})`;
811
+ // Runtime bindings are keyed by "<port>/<protocol>" — Docker
812
+ // emits `tcp` / `udp` lowercased. Match protocol defensively.
813
+ const key = `${port}/${proto.toLowerCase()}`;
814
+ const binding = runtime[key];
815
+ if (binding) {
816
+ desc += ` → ${binding}`;
817
+ }
818
+ return { text: String(port), description: desc };
819
+ });
820
+
821
+ // Surface runtime-only ports (exposed by the container but not
822
+ // listed in forwardPorts) so users see the full picture.
823
+ for (const [key, binding] of Object.entries(runtime)) {
824
+ const slash = key.indexOf("/");
825
+ const portStr = slash >= 0 ? key.slice(0, slash) : key;
826
+ const portNum = Number(portStr);
827
+ const alreadyListed =
828
+ config.forwardPorts.some((p) => String(p) === portStr) ||
829
+ (!Number.isNaN(portNum) && config.forwardPorts.some((p) => p === portNum));
830
+ if (alreadyListed) continue;
831
+ suggestions.push({
832
+ text: portStr,
833
+ description: `${key} · runtime only → ${binding}`,
834
+ });
835
+ }
836
+
837
+ editor.startPrompt(editor.t("prompt.ports"), "devcontainer-ports");
838
+ editor.setPromptSuggestions(suggestions);
839
+ }
840
+ registerHandler("devcontainer_show_ports", devcontainer_show_ports);
841
+
842
+ // =============================================================================
843
+ // Forwarded Ports Panel (spec §7)
844
+ // =============================================================================
845
+ //
846
+ // Phase A's `devcontainer_show_ports` is a prompt-picker: quick
847
+ // lookups for "did this port actually bind?" E-3 extends that with a
848
+ // standalone panel so users can see configured + runtime-bound ports
849
+ // at a glance rather than scrolling a picker.
850
+ //
851
+ // Data sources (identical to the picker):
852
+ // - `config.forwardPorts` — declared port forwards
853
+ // - `config.portsAttributes` — optional label / protocol / policy
854
+ // - `docker port <id>` — runtime host binding per (port, proto)
855
+ //
856
+ // Layout: four columns — Configured | Protocol | Label | Runtime binding —
857
+ // followed by any runtime-only ports (container exposed but not in
858
+ // `forwardPorts`). Refresh key `r` re-runs `docker port` and rebuilds
859
+ // the buffer. Close via `q` / Escape.
860
+
861
+ let portsPanelBufferId: number | null = null;
862
+ let portsPanelSplitId: number | null = null;
863
+ let portsPanelOpen = false;
864
+
865
+ type PortRow = {
866
+ port: string;
867
+ protocol: string;
868
+ label: string;
869
+ binding: string;
870
+ source: "configured" | "runtime";
871
+ };
872
+
873
+ async function gatherForwardedPortRows(): Promise<PortRow[]> {
874
+ let runtime: Record<string, string> = {};
875
+ const authorityLabel = editor.getAuthorityLabel();
876
+ const prefix = "Container:";
877
+ if (authorityLabel.startsWith(prefix)) {
878
+ const containerId = authorityLabel.slice(prefix.length);
879
+ if (containerId.length > 0) {
880
+ const which = await editor.spawnHostProcess("which", ["docker"]);
881
+ if (which.exit_code === 0) {
882
+ const res = await editor.spawnHostProcess(
883
+ "docker",
884
+ ["port", containerId],
885
+ editor.getCwd(),
886
+ );
887
+ if (res.exit_code === 0) {
888
+ runtime = parseDockerPortOutput(res.stdout);
889
+ }
890
+ }
891
+ }
892
+ }
893
+
894
+ const rows: PortRow[] = [];
895
+ const configured = config?.forwardPorts ?? [];
896
+ for (const port of configured) {
897
+ const attrs = config?.portsAttributes?.[String(port)];
898
+ const protocol = attrs?.protocol ?? "tcp";
899
+ const key = `${port}/${protocol.toLowerCase()}`;
900
+ const binding = runtime[key] ?? "";
901
+ const labelParts: string[] = [];
902
+ if (attrs?.label) labelParts.push(attrs.label);
903
+ if (attrs?.onAutoForward) labelParts.push(`(${attrs.onAutoForward})`);
904
+ rows.push({
905
+ port: String(port),
906
+ protocol,
907
+ label: labelParts.join(" "),
908
+ binding,
909
+ source: "configured",
910
+ });
911
+ }
912
+
913
+ // Runtime-only ports: the container exposed them but they aren't in
914
+ // `forwardPorts`. Worth surfacing so users see the full picture.
915
+ for (const [key, binding] of Object.entries(runtime)) {
916
+ const slash = key.indexOf("/");
917
+ const portStr = slash >= 0 ? key.slice(0, slash) : key;
918
+ const proto = slash >= 0 ? key.slice(slash + 1) : "tcp";
919
+ const portNum = Number(portStr);
920
+ const alreadyListed =
921
+ configured.some((p) => String(p) === portStr) ||
922
+ (!Number.isNaN(portNum) && configured.some((p) => p === portNum));
923
+ if (alreadyListed) continue;
924
+ rows.push({
925
+ port: portStr,
926
+ protocol: proto,
927
+ label: "",
928
+ binding,
929
+ source: "runtime",
930
+ });
931
+ }
932
+ return rows;
933
+ }
934
+
935
+ function buildPortsPanelEntries(rows: PortRow[]): TextPropertyEntry[] {
936
+ const entries: TextPropertyEntry[] = [];
937
+
938
+ entries.push({
939
+ text: editor.t("ports_panel.header") + "\n",
940
+ properties: { type: "heading" },
941
+ });
942
+ entries.push({ text: "\n", properties: { type: "blank" } });
943
+
944
+ if (rows.length === 0) {
945
+ entries.push({
946
+ text: " " + editor.t("ports_panel.no_ports") + "\n",
947
+ properties: { type: "value" },
948
+ });
949
+ entries.push({ text: "\n", properties: { type: "blank" } });
950
+ } else {
951
+ // Column widths — pick the larger of the header width or the
952
+ // longest value so the header stays aligned even when all rows
953
+ // are shorter than the label.
954
+ const headers = {
955
+ port: editor.t("ports_panel.col_configured"),
956
+ protocol: editor.t("ports_panel.col_protocol"),
957
+ label: editor.t("ports_panel.col_label"),
958
+ binding: editor.t("ports_panel.col_binding"),
959
+ };
960
+ const width = (label: string, values: string[]): number =>
961
+ Math.max(label.length, ...values.map((v) => v.length));
962
+ const portW = width(
963
+ headers.port,
964
+ rows.map((r) => r.port),
965
+ );
966
+ const protoW = width(
967
+ headers.protocol,
968
+ rows.map((r) => r.protocol),
969
+ );
970
+ const labelW = width(
971
+ headers.label,
972
+ rows.map((r) => r.label),
973
+ );
974
+ const bindingW = width(
975
+ headers.binding,
976
+ rows.map((r) => r.binding),
977
+ );
978
+ const pad = (s: string, n: number): string =>
979
+ s + " ".repeat(Math.max(0, n - s.length));
980
+
981
+ const headerLine =
982
+ " " +
983
+ pad(headers.port, portW) +
984
+ " " +
985
+ pad(headers.protocol, protoW) +
986
+ " " +
987
+ pad(headers.label, labelW) +
988
+ " " +
989
+ pad(headers.binding, bindingW);
990
+ entries.push({
991
+ text: headerLine + "\n",
992
+ properties: { type: "heading" },
993
+ });
994
+ const rule =
995
+ " " +
996
+ "─".repeat(portW) +
997
+ " " +
998
+ "─".repeat(protoW) +
999
+ " " +
1000
+ "─".repeat(labelW) +
1001
+ " " +
1002
+ "─".repeat(bindingW);
1003
+ entries.push({
1004
+ text: rule + "\n",
1005
+ properties: { type: "separator" },
1006
+ });
1007
+
1008
+ for (const row of rows) {
1009
+ const rendered =
1010
+ " " +
1011
+ pad(row.port, portW) +
1012
+ " " +
1013
+ pad(row.protocol, protoW) +
1014
+ " " +
1015
+ pad(row.label, labelW) +
1016
+ " " +
1017
+ pad(row.binding || "—", bindingW);
1018
+ entries.push({
1019
+ text: rendered + "\n",
1020
+ properties: { type: "port-row", source: row.source },
1021
+ });
1022
+ }
1023
+ entries.push({ text: "\n", properties: { type: "blank" } });
1024
+ }
1025
+
1026
+ entries.push({
1027
+ text: editor.t("ports_panel.footer") + "\n",
1028
+ properties: { type: "footer" },
1029
+ });
1030
+
1031
+ return entries;
1032
+ }
1033
+
1034
+ async function renderPortsPanel(): Promise<void> {
1035
+ if (portsPanelBufferId === null) return;
1036
+ const rows = await gatherForwardedPortRows();
1037
+ const entries = buildPortsPanelEntries(rows);
1038
+ editor.setVirtualBufferContent(portsPanelBufferId, entries);
1039
+ }
1040
+
1041
+ async function devcontainer_show_forwarded_ports_panel(): Promise<void> {
1042
+ if (!config) {
1043
+ editor.setStatus(editor.t("status.no_config"));
1044
+ return;
1045
+ }
1046
+
1047
+ if (portsPanelOpen && portsPanelBufferId !== null) {
1048
+ await renderPortsPanel();
1049
+ return;
1050
+ }
1051
+
1052
+ const rows = await gatherForwardedPortRows();
1053
+ const entries = buildPortsPanelEntries(rows);
1054
+ const result = await editor.createVirtualBufferInSplit({
1055
+ name: "*Dev Container Ports*",
1056
+ mode: "devcontainer-ports",
1057
+ readOnly: true,
1058
+ showLineNumbers: false,
1059
+ showCursors: true,
1060
+ editingDisabled: true,
1061
+ lineWrap: true,
1062
+ ratio: 0.35,
1063
+ direction: "horizontal",
1064
+ entries,
1065
+ });
1066
+ if (result !== null) {
1067
+ portsPanelOpen = true;
1068
+ portsPanelBufferId = result.bufferId;
1069
+ portsPanelSplitId = result.splitId;
1070
+ editor.setStatus(editor.t("status.ports_panel_opened"));
1071
+ }
1072
+ }
1073
+ registerHandler(
1074
+ "devcontainer_show_forwarded_ports_panel",
1075
+ devcontainer_show_forwarded_ports_panel,
1076
+ );
1077
+
1078
+ async function devcontainer_refresh_ports_panel(): Promise<void> {
1079
+ if (!portsPanelOpen) return;
1080
+ await renderPortsPanel();
1081
+ editor.setStatus(editor.t("status.ports_panel_refreshed"));
1082
+ }
1083
+ registerHandler(
1084
+ "devcontainer_refresh_ports_panel",
1085
+ devcontainer_refresh_ports_panel,
1086
+ );
1087
+
1088
+ function devcontainer_close_ports_panel(): void {
1089
+ if (!portsPanelOpen) return;
1090
+ if (portsPanelSplitId !== null) {
1091
+ editor.closeSplit(portsPanelSplitId);
1092
+ }
1093
+ if (portsPanelBufferId !== null) {
1094
+ editor.closeBuffer(portsPanelBufferId);
1095
+ }
1096
+ portsPanelOpen = false;
1097
+ portsPanelBufferId = null;
1098
+ portsPanelSplitId = null;
1099
+ }
1100
+ registerHandler(
1101
+ "devcontainer_close_ports_panel",
1102
+ devcontainer_close_ports_panel,
1103
+ );
1104
+
1105
+ editor.defineMode(
1106
+ "devcontainer-ports",
1107
+ [
1108
+ ["r", "devcontainer_refresh_ports_panel"],
1109
+ ["q", "devcontainer_close_ports_panel"],
1110
+ ["Escape", "devcontainer_close_ports_panel"],
1111
+ ],
1112
+ true, // read-only
1113
+ false, // allow_text_input
1114
+ true, // inherit Normal-context bindings so arrow keys / page nav still work
1115
+ );
1116
+
1117
+ const INSTALL_COMMAND = "npm i -g @devcontainers/cli";
1118
+
1119
+ interface ActionPopupResultData {
1120
+ popup_id: string;
1121
+ action_id: string;
1122
+ }
1123
+
1124
+ function showCliNotFoundPopup(): void {
1125
+ editor.showActionPopup({
1126
+ id: "devcontainer-cli-help",
1127
+ title: editor.t("popup.cli_title"),
1128
+ message: editor.t("popup.cli_message"),
1129
+ actions: [
1130
+ { id: "copy_install", label: "Copy: " + INSTALL_COMMAND },
1131
+ { id: "dismiss", label: "Dismiss (ESC)" },
1132
+ ],
1133
+ });
1134
+ }
1135
+
1136
+ function devcontainer_on_action_result(data: ActionPopupResultData): void {
1137
+ if (data.popup_id === "devcontainer-cli-help") {
1138
+ switch (data.action_id) {
1139
+ case "copy_install":
1140
+ editor.setClipboard(INSTALL_COMMAND);
1141
+ editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
1142
+ break;
1143
+ case "dismiss":
1144
+ case "dismissed":
1145
+ break;
1146
+ }
1147
+ return;
1148
+ }
1149
+ if (data.popup_id === "devcontainer-attach") {
1150
+ devcontainer_on_attach_popup(data);
1151
+ return;
1152
+ }
1153
+ if (data.popup_id === "devcontainer-failed-attach") {
1154
+ devcontainer_on_failed_attach_popup(data);
1155
+ }
1156
+ }
1157
+ registerHandler("devcontainer_on_action_result", devcontainer_on_action_result);
1158
+
1159
+ /// Surface a proactive action popup after a failed attach so users
1160
+ /// don't have to notice the Remote Indicator's red state on their own.
1161
+ /// Spec §8 calls for "Retry" / "Reopen Locally" on build failure; we
1162
+ /// also offer "Show Build Logs" (the file is still on disk — see
1163
+ /// `prepareBuildLogFile`) and a "Dismiss" escape so the user can come
1164
+ /// back later via the Remote Indicator menu without the popup blocking.
1165
+ ///
1166
+ /// All four actions map to existing handlers:
1167
+ /// - Retry → `devcontainer_retry_attach`
1168
+ /// - Show Build Logs → `devcontainer_show_build_logs`
1169
+ /// - Reopen Locally → `clearRemoteIndicatorState` (no authority was
1170
+ /// installed, so nothing to detach; just drop the red override).
1171
+ /// - Dismiss → no-op; FailedAttach indicator stays so the user can
1172
+ /// revisit the choice from the Remote Indicator popup.
1173
+ function showFailedAttachPopup(errText: string): void {
1174
+ editor.showActionPopup({
1175
+ id: "devcontainer-failed-attach",
1176
+ title: editor.t("popup.failed_attach_title"),
1177
+ message: editor.t("popup.failed_attach_message", { error: errText }),
1178
+ actions: [
1179
+ { id: "retry", label: editor.t("popup.failed_attach_action_retry") },
1180
+ {
1181
+ id: "show_build_logs",
1182
+ label: editor.t("popup.failed_attach_action_show_logs"),
1183
+ },
1184
+ {
1185
+ id: "reopen_local",
1186
+ label: editor.t("popup.failed_attach_action_reopen_local"),
1187
+ },
1188
+ { id: "dismiss", label: editor.t("popup.failed_attach_action_dismiss") },
1189
+ ],
1190
+ });
1191
+ }
1192
+
1193
+ function devcontainer_on_failed_attach_popup(data: ActionPopupResultData): void {
1194
+ if (data.popup_id !== "devcontainer-failed-attach") return;
1195
+ switch (data.action_id) {
1196
+ case "retry":
1197
+ void devcontainer_retry_attach();
1198
+ break;
1199
+ case "show_build_logs":
1200
+ void devcontainer_show_build_logs();
1201
+ break;
1202
+ case "reopen_local":
1203
+ // No authority was installed — failed attach never got that far —
1204
+ // so there is nothing to detach. Just drop the FailedAttach
1205
+ // override so the indicator returns to Local.
1206
+ editor.clearRemoteIndicatorState();
1207
+ break;
1208
+ case "dismiss":
1209
+ case "dismissed":
1210
+ // Leave the FailedAttach indicator visible so the user can revisit
1211
+ // via the Remote Indicator popup later.
1212
+ break;
1213
+ }
1214
+ }
1215
+ registerHandler(
1216
+ "devcontainer_on_failed_attach_popup",
1217
+ devcontainer_on_failed_attach_popup,
1218
+ );
1219
+
1220
+ /// Convenience wrapper: flip the indicator to FailedAttach, set the
1221
+ /// rebuild-failed status message, and surface the proactive action
1222
+ /// popup in one call. Every branch in `runDevcontainerUp` that reaches
1223
+ /// the failure state routes through here so the popup surfaces
1224
+ /// consistently regardless of which step failed.
1225
+ function enterFailedAttach(errText: string): void {
1226
+ editor.setStatus(editor.t("status.rebuild_failed", { error: errText }));
1227
+ editor.setRemoteIndicatorState({
1228
+ kind: "failed_attach",
1229
+ error: errText,
1230
+ });
1231
+ showFailedAttachPopup(errText);
1232
+ }
1233
+
1234
+ // =============================================================================
1235
+ // Authority lifecycle
1236
+ // =============================================================================
1237
+ //
1238
+ // "Attach" = run `devcontainer up` on the host and install a container
1239
+ // authority via editor.setAuthority({...}). The authority transition
1240
+ // restarts the editor so every cached filesystem handle / LSP / PTY
1241
+ // gets recreated against the new backend. We use spawnHostProcess for
1242
+ // the CLI call so that a plugin triggering rebuild from inside an
1243
+ // already-attached session still runs on the host, not inside the
1244
+ // container that is about to be destroyed.
1245
+
1246
+ interface DevcontainerUpResult {
1247
+ outcome?: string;
1248
+ containerId?: string;
1249
+ remoteUser?: string;
1250
+ remoteWorkspaceFolder?: string;
1251
+ }
1252
+
1253
+ function parseDevcontainerUpOutput(stdout: string): DevcontainerUpResult | null {
1254
+ const lines = stdout.split("\n");
1255
+ for (let i = lines.length - 1; i >= 0; i--) {
1256
+ const line = lines[i].trim();
1257
+ if (!line.startsWith("{")) continue;
1258
+ try {
1259
+ return JSON.parse(line) as DevcontainerUpResult;
1260
+ } catch {
1261
+ continue;
1262
+ }
1263
+ }
1264
+ return null;
1265
+ }
1266
+
1267
+ function buildContainerAuthorityPayload(
1268
+ result: DevcontainerUpResult,
1269
+ ): AuthorityPayload | null {
1270
+ if (!result.containerId) return null;
1271
+ const user = result.remoteUser ?? null;
1272
+ const workspace = result.remoteWorkspaceFolder ?? null;
1273
+
1274
+ const args: string[] = ["exec", "-it"];
1275
+ if (user) {
1276
+ args.push("-u", user);
1277
+ }
1278
+ if (workspace) {
1279
+ args.push("-w", workspace);
1280
+ }
1281
+ args.push(result.containerId, "bash", "-l");
1282
+
1283
+ const shortId = result.containerId.slice(0, 12);
1284
+
1285
+ return {
1286
+ filesystem: { kind: "local" },
1287
+ spawner: {
1288
+ kind: "docker-exec",
1289
+ container_id: result.containerId,
1290
+ user,
1291
+ workspace,
1292
+ },
1293
+ terminal_wrapper: {
1294
+ kind: "explicit",
1295
+ command: "docker",
1296
+ args,
1297
+ manages_cwd: true,
1298
+ },
1299
+ display_label: "Container:" + shortId,
1300
+ };
1301
+ }
1302
+
1303
+ /// Run `initializeCommand` on the host before container lifecycle
1304
+ /// hooks. Per the dev-container spec this is the "host-side
1305
+ /// prologue" — it runs before `devcontainer up` and has no
1306
+ /// container to be in. The `devcontainer` CLI does not invoke it
1307
+ /// automatically; Fresh is the layer that has to.
1308
+ ///
1309
+ /// Returns `true` on success or when no initializeCommand is defined;
1310
+ /// `false` and sets a user-visible failure status when the command
1311
+ /// exits non-zero, so callers can short-circuit the attach.
1312
+ async function runInitializeCommand(): Promise<boolean> {
1313
+ const cmd = config?.initializeCommand;
1314
+ if (!cmd) {
1315
+ return true;
1316
+ }
1317
+
1318
+ editor.setStatus(editor.t("status.running", { name: "initializeCommand" }));
1319
+ const cwd = editor.getCwd();
1320
+
1321
+ async function runOne(bin: string, args: string[]): Promise<number> {
1322
+ const res = await editor.spawnHostProcess(bin, args, cwd);
1323
+ return res.exit_code;
1324
+ }
1325
+
1326
+ let exitCode: number;
1327
+ if (typeof cmd === "string") {
1328
+ exitCode = await runOne("sh", ["-c", cmd]);
1329
+ } else if (Array.isArray(cmd)) {
1330
+ const [bin, ...rest] = cmd;
1331
+ exitCode = await runOne(bin, rest);
1332
+ } else {
1333
+ // Object form: run each named subcommand sequentially, bail on
1334
+ // first failure. Matches the semantics of the per-hook runner
1335
+ // in devcontainer_on_lifecycle_confirmed below.
1336
+ exitCode = 0;
1337
+ for (const [label, subcmd] of Object.entries(cmd)) {
1338
+ let bin: string;
1339
+ let args: string[];
1340
+ if (Array.isArray(subcmd)) {
1341
+ [bin, ...args] = subcmd;
1342
+ } else {
1343
+ bin = "sh";
1344
+ args = ["-c", subcmd as string];
1345
+ }
1346
+ editor.setStatus(
1347
+ editor.t("status.running_sub", { name: "initializeCommand", label }),
1348
+ );
1349
+ const res = await runOne(bin, args);
1350
+ if (res !== 0) {
1351
+ exitCode = res;
1352
+ editor.setStatus(
1353
+ editor.t("status.failed_sub", {
1354
+ name: "initializeCommand",
1355
+ label,
1356
+ code: String(res),
1357
+ }),
1358
+ );
1359
+ return false;
1360
+ }
1361
+ }
1362
+ }
1363
+
1364
+ if (exitCode !== 0) {
1365
+ editor.setStatus(
1366
+ editor.t("status.failed", {
1367
+ name: "initializeCommand",
1368
+ code: String(exitCode),
1369
+ }),
1370
+ );
1371
+ return false;
1372
+ }
1373
+ return true;
1374
+ }
1375
+
1376
+ async function runDevcontainerUp(extraArgs: string[]): Promise<void> {
1377
+ const cwd = editor.getCwd();
1378
+ const which = await editor.spawnHostProcess("which", ["devcontainer"]);
1379
+ if (which.exit_code !== 0) {
1380
+ showCliNotFoundPopup();
1381
+ return;
1382
+ }
1383
+
1384
+ // The Remote Indicator goes into "Connecting · <phase>" for the
1385
+ // duration of the attach so users see progress; cleared (or
1386
+ // replaced with FailedAttach) by the explicit transitions below.
1387
+ editor.setRemoteIndicatorState({
1388
+ kind: "connecting",
1389
+ label: editor.t("indicator.phase_initialize"),
1390
+ });
1391
+
1392
+ // initializeCommand runs on the host BEFORE `devcontainer up`, per
1393
+ // spec. Bail the attach if it fails; the user shouldn't get an
1394
+ // attached container after their host-side prologue errored.
1395
+ if (!(await runInitializeCommand())) {
1396
+ enterFailedAttach(editor.t("indicator.error_initialize"));
1397
+ return;
1398
+ }
1399
+
1400
+ editor.setRemoteIndicatorState({
1401
+ kind: "connecting",
1402
+ label: editor.t("indicator.phase_build"),
1403
+ });
1404
+ editor.setStatus(editor.t("status.rebuilding"));
1405
+
1406
+ // Redirect `devcontainer up`'s stderr into a workspace-scoped log
1407
+ // file; let stdout flow back through the existing pipe so we parse
1408
+ // the success JSON from `result.stdout` as before. This mirrors
1409
+ // the CLI's stream contract: stdout = machine-readable result;
1410
+ // stderr = human-readable progress / errors. The log file holds
1411
+ // exactly the "progress/errors" half.
1412
+ //
1413
+ // Rationale for the file:
1414
+ // - "Show Build Logs" is just `openFile(path)` — no new API.
1415
+ // - Fresh's auto-revert (2s poll) streams lines into the buffer
1416
+ // as they arrive; user sees live progress without special
1417
+ // plumbing.
1418
+ // - Path is under the workspace, so bind-mount coincidence keeps
1419
+ // it reachable post-attach (container auth sees the same file).
1420
+ // - `.fresh-cache/.gitignore = *` self-ignores the cache dir
1421
+ // without forcing users to touch their own `.gitignore`.
1422
+ const logPath = await prepareBuildLogFile(cwd);
1423
+ if (!logPath) {
1424
+ enterFailedAttach(editor.t("status.build_log_prepare_failed"));
1425
+ return;
1426
+ }
1427
+ rememberLastBuildLogPath(logPath);
1428
+ // Open the log in a split below so the user sees lines stream in
1429
+ // (auto-revert polls every 2s) without losing the buffer they were
1430
+ // editing. `split_horizontal` duplicates the current buffer into a
1431
+ // new split and focuses it; openFile then swaps the new split's
1432
+ // buffer for the log. Non-fatal if either step fails — the build
1433
+ // continues either way.
1434
+ openBuildLogInSplit(logPath);
1435
+
1436
+ // `sh -c 'exec devcontainer "$@" 2> "$LOG"' sh <log> <args...>` —
1437
+ // positional-arg form so the log path and cwd never get
1438
+ // string-interpolated into the script body. $1 is the log path;
1439
+ // `shift` drops it; `$@` is the devcontainer invocation.
1440
+ const shellScript = 'LOG="$1"; shift; exec devcontainer "$@" 2> "$LOG"';
1441
+ const args = [
1442
+ "-c",
1443
+ shellScript,
1444
+ "sh",
1445
+ logPath,
1446
+ "up",
1447
+ "--workspace-folder",
1448
+ cwd,
1449
+ ...extraArgs,
1450
+ ];
1451
+ const handle = editor.spawnHostProcess("sh", args);
1452
+ attachInFlight = handle;
1453
+ attachCancelled = false;
1454
+ let result: SpawnResult;
1455
+ try {
1456
+ result = await handle;
1457
+ } finally {
1458
+ attachInFlight = null;
1459
+ }
1460
+
1461
+ // Cancel path: `devcontainer_cancel_attach` set `attachCancelled`
1462
+ // and flipped the indicator back to Local already. The non-zero
1463
+ // exit coming out of `Child::start_kill()` is not an error.
1464
+ if (attachCancelled) {
1465
+ attachCancelled = false;
1466
+ return;
1467
+ }
1468
+
1469
+ if (result.exit_code !== 0) {
1470
+ // On failure the log file holds the stderr trace — surface its
1471
+ // last non-empty line as a human-readable status blurb. This
1472
+ // is purely cosmetic; exit_code drove the branch.
1473
+ const logText = editor.readFile(logPath) ?? "";
1474
+ const errText = extractLastNonEmptyLine(logText)
1475
+ ?? `exit ${result.exit_code}`;
1476
+ enterFailedAttach(errText);
1477
+ return;
1478
+ }
1479
+
1480
+ const parsed = parseDevcontainerUpOutput(result.stdout);
1481
+ if (!parsed || parsed.outcome !== "success" || !parsed.containerId) {
1482
+ enterFailedAttach(editor.t("status.rebuild_parse_failed"));
1483
+ return;
1484
+ }
1485
+
1486
+ const payload = buildContainerAuthorityPayload(parsed);
1487
+ if (!payload) {
1488
+ enterFailedAttach(editor.t("status.rebuild_missing_container_id"));
1489
+ return;
1490
+ }
1491
+
1492
+ // setAuthority fires the restart flow in core. The status message
1493
+ // we set here won't survive the restart; the plugin will re-init
1494
+ // with the new authority active and print status.detected again.
1495
+ //
1496
+ // Write the attempt breadcrumb immediately before so the post-
1497
+ // restart plugin instance can detect "attach was in flight" and
1498
+ // decide between success (container authority live) and silent
1499
+ // failure (no authority landed — surfaces as FailedAttach).
1500
+ writeAttachAttempt();
1501
+ editor.setAuthority(payload);
1502
+ }
1503
+
1504
+ // Lay out `.fresh-cache/devcontainer-logs/<timestamp>.log` under the
1505
+ // workspace. Returns the log path on success, null on failure
1506
+ // (mkdir denied, etc.). The directory carries its own
1507
+ // `.gitignore = *` so the cache never leaks into a commit without
1508
+ // the user touching their top-level `.gitignore`.
1509
+ async function prepareBuildLogFile(cwd: string): Promise<string | null> {
1510
+ const cacheDir = `${cwd}/.fresh-cache`;
1511
+ const logDir = `${cacheDir}/devcontainer-logs`;
1512
+ const mkRes = await editor.spawnHostProcess("mkdir", ["-p", logDir]);
1513
+ if (mkRes.exit_code !== 0) {
1514
+ editor.debug(
1515
+ `devcontainer: mkdir -p ${logDir} failed: ${mkRes.stderr.trim()}`,
1516
+ );
1517
+ return null;
1518
+ }
1519
+ const cacheIgnore = `${cacheDir}/.gitignore`;
1520
+ if (editor.readFile(cacheIgnore) === null) {
1521
+ // writeFile failure is non-fatal — worst case the user sees
1522
+ // `.fresh-cache/` in `git status` once.
1523
+ editor.writeFile(cacheIgnore, "*\n");
1524
+ }
1525
+ // `toISOString()` → "2026-04-21T12:34:56.789Z"; strip the ms+Z
1526
+ // and swap separators that are awkward in filenames on some
1527
+ // platforms.
1528
+ const ts = new Date()
1529
+ .toISOString()
1530
+ .replace(/\.\d+Z$/, "")
1531
+ .replace(/:/g, "-")
1532
+ .replace("T", "_");
1533
+ return `${logDir}/build-${ts}.log`;
1534
+ }
1535
+
1536
+ function lastBuildLogKey(): string {
1537
+ return "last-build-log:" + editor.getCwd();
1538
+ }
1539
+
1540
+ /// Open the build log file in a horizontal split below the current
1541
+ /// pane, leaving whatever the user was editing in the top split. Used
1542
+ /// both during the live build (so users see progress without losing
1543
+ /// their working buffer) and from `devcontainer_show_build_logs` so
1544
+ /// the post-attach access path doesn't replace the user's file
1545
+ /// either.
1546
+ ///
1547
+ /// Dedupe uses `BufferInfo.splits` from `listBuffers()` — if the log
1548
+ /// is already visible in some split, focus that split. Otherwise
1549
+ /// split + openFile. Reading the current snapshot each call (rather
1550
+ /// than tracking split ids in module state) means the dedupe
1551
+ /// survives the post-attach editor restart: after setAuthority
1552
+ /// rebuilds the editor and workspace restore brings the log buffer
1553
+ /// back, the first `Show Build Logs` finds the restored split and
1554
+ /// focuses it instead of stacking a new one on top.
1555
+ function openBuildLogInSplit(path: string): void {
1556
+ const buffers = editor.listBuffers();
1557
+ const existing = buffers.find((b) => b.path === path);
1558
+ if (existing && existing.splits.length > 0) {
1559
+ editor.focusSplit(existing.splits[0]);
1560
+ return;
1561
+ }
1562
+ // Not visible anywhere → create a new split and open the log
1563
+ // there. openFile reuses the buffer when the path is already
1564
+ // loaded (e.g. open but not in any split), so no duplicate
1565
+ // buffers either way.
1566
+ editor.executeAction("split_horizontal");
1567
+ editor.openFile(path, null, null);
1568
+ }
1569
+
1570
+ function rememberLastBuildLogPath(path: string): void {
1571
+ editor.setGlobalState(lastBuildLogKey(), path);
1572
+ }
1573
+
1574
+ function readLastBuildLogPath(): string | null {
1575
+ const raw = editor.getGlobalState(lastBuildLogKey()) as unknown;
1576
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
1577
+ }
1578
+
1579
+ function extractLastNonEmptyLine(text: string): string | null {
1580
+ const lines = text.split("\n");
1581
+ for (let i = lines.length - 1; i >= 0; i--) {
1582
+ const t = lines[i].trim();
1583
+ if (t.length > 0) return t;
1584
+ }
1585
+ return null;
1586
+ }
1587
+
1588
+ async function devcontainer_attach(): Promise<void> {
1589
+ if (!config) {
1590
+ editor.setStatus(editor.t("status.no_config"));
1591
+ return;
1592
+ }
1593
+ await runDevcontainerUp([]);
1594
+ }
1595
+ registerHandler("devcontainer_attach", devcontainer_attach);
1596
+
1597
+ async function devcontainer_rebuild(): Promise<void> {
1598
+ if (!config) {
1599
+ editor.setStatus(editor.t("status.no_config"));
1600
+ return;
1601
+ }
1602
+ await runDevcontainerUp(["--remove-existing-container"]);
1603
+ }
1604
+ registerHandler("devcontainer_rebuild", devcontainer_rebuild);
1605
+
1606
+ /// Retry a previously-failed attach. Thin wrapper around
1607
+ /// `devcontainer_attach` — exists so the Remote Indicator popup's
1608
+ /// FailedAttach branch can dispatch something named `retry_attach`
1609
+ /// without hard-coding an implementation detail. Also the natural
1610
+ /// single call site if we ever want to add backoff or attempt
1611
+ /// counting.
1612
+ async function devcontainer_retry_attach(): Promise<void> {
1613
+ // Drop the stale FailedAttach state before the new attempt so
1614
+ // the popup shows the freshly-entered Connecting state
1615
+ // immediately; setRemoteIndicatorState inside runDevcontainerUp
1616
+ // will override again.
1617
+ editor.clearRemoteIndicatorState();
1618
+ await devcontainer_attach();
1619
+ }
1620
+ registerHandler("devcontainer_retry_attach", devcontainer_retry_attach);
1621
+
1622
+ async function devcontainer_detach(): Promise<void> {
1623
+ editor.clearAuthority();
1624
+ }
1625
+ registerHandler("devcontainer_detach", devcontainer_detach);
1626
+
1627
+ /// Abort an in-flight attach by killing the `devcontainer up` host
1628
+ /// spawn. No-op when nothing is in flight. The indicator is flipped
1629
+ /// back to Local immediately — cancel is a user-initiated revert,
1630
+ /// not a failure, so we don't go through FailedAttach.
1631
+ async function devcontainer_cancel_attach(): Promise<void> {
1632
+ const handle = attachInFlight;
1633
+ if (!handle) {
1634
+ editor.setStatus(editor.t("status.cancel_nothing_in_flight"));
1635
+ return;
1636
+ }
1637
+ // Order matters: set the flag before kill() so the awaiting
1638
+ // runDevcontainerUp sees `attachCancelled = true` when the
1639
+ // terminal event arrives, and takes the silent-return path
1640
+ // instead of painting FailedAttach on top of the Local we're
1641
+ // about to install.
1642
+ attachCancelled = true;
1643
+ editor.setRemoteIndicatorState({ kind: "local" });
1644
+ editor.setStatus(editor.t("status.attach_cancelled"));
1645
+ // `.kill()` returns a Promise<boolean> from the TS wrapper — we
1646
+ // don't need the boolean; the kill is fire-and-forget.
1647
+ void handle.kill();
1648
+ }
1649
+ registerHandler("devcontainer_cancel_attach", devcontainer_cancel_attach);
1650
+
1651
+ /// Open the build log from the most recent `devcontainer up` in a
1652
+ /// buffer. The path was remembered across restarts via
1653
+ /// `setGlobalState`, so this works both during Connecting (log is
1654
+ /// still being appended — Fresh's auto-revert shows live updates)
1655
+ /// and after a FailedAttach / successful attach.
1656
+ async function devcontainer_show_build_logs(): Promise<void> {
1657
+ const path = readLastBuildLogPath();
1658
+ if (!path) {
1659
+ editor.setStatus(editor.t("status.no_build_log"));
1660
+ return;
1661
+ }
1662
+ if (editor.readFile(path) === null) {
1663
+ editor.setStatus(editor.t("status.build_log_missing"));
1664
+ return;
1665
+ }
1666
+ openBuildLogInSplit(path);
1667
+ }
1668
+ registerHandler("devcontainer_show_build_logs", devcontainer_show_build_logs);
1669
+
1670
+ /// Show a one-shot snapshot of the attached container's stdout/stderr
1671
+ /// via `docker logs --tail 1000 <id>`. The log is rendered into a
1672
+ /// read-only virtual buffer split; closing the split discards the
1673
+ /// snapshot (re-run the command for a refresh).
1674
+ ///
1675
+ /// Host-side by design: we talk to the `docker` CLI from outside the
1676
+ /// container so this works even when the container is mid-reboot or
1677
+ /// has no shell. The container id comes from the active authority's
1678
+ /// display label ("Container:<shortid>") rather than re-parsing the
1679
+ /// `devcontainer up` JSON — plugins own the authority surface, core
1680
+ /// owns the label.
1681
+ async function devcontainer_show_logs(): Promise<void> {
1682
+ const authorityLabel = editor.getAuthorityLabel();
1683
+ const prefix = "Container:";
1684
+ if (!authorityLabel.startsWith(prefix)) {
1685
+ editor.setStatus(editor.t("status.logs_require_container"));
1686
+ return;
1687
+ }
1688
+ const containerId = authorityLabel.slice(prefix.length);
1689
+ if (containerId.length === 0) {
1690
+ editor.setStatus(editor.t("status.logs_require_container"));
1691
+ return;
1692
+ }
1693
+
1694
+ const which = await editor.spawnHostProcess("which", ["docker"]);
1695
+ if (which.exit_code !== 0) {
1696
+ editor.setStatus(editor.t("status.logs_docker_missing"));
1697
+ return;
1698
+ }
1699
+
1700
+ editor.setStatus(editor.t("status.logs_loading"));
1701
+ const res = await editor.spawnHostProcess(
1702
+ "docker",
1703
+ ["logs", "--tail", "1000", containerId],
1704
+ editor.getCwd(),
1705
+ );
1706
+
1707
+ // `docker logs` emits container stdout on our stdout and container
1708
+ // stderr on our stderr — merge them with a leading marker so the
1709
+ // user can tell them apart in the buffer.
1710
+ const mergedParts: string[] = [];
1711
+ if (res.stdout.length > 0) {
1712
+ mergedParts.push(res.stdout);
1713
+ }
1714
+ if (res.stderr.length > 0) {
1715
+ mergedParts.push("--- stderr ---\n" + res.stderr);
1716
+ }
1717
+ const merged = mergedParts.join("\n").length > 0
1718
+ ? mergedParts.join("\n")
1719
+ : editor.t("status.logs_empty");
1720
+
1721
+ const result = await editor.createVirtualBufferInSplit({
1722
+ name: "*Dev Container Logs*",
1723
+ mode: "devcontainer-info",
1724
+ readOnly: true,
1725
+ showLineNumbers: false,
1726
+ showCursors: true,
1727
+ editingDisabled: true,
1728
+ lineWrap: true,
1729
+ ratio: 0.4,
1730
+ direction: "horizontal",
1731
+ entries: [{ text: merged, properties: { type: "log" } }],
1732
+ });
1733
+ if (result !== null) {
1734
+ editor.setStatus(editor.t("status.logs_shown"));
1735
+ }
1736
+ }
1737
+ registerHandler("devcontainer_show_logs", devcontainer_show_logs);
1738
+
1739
+ // =============================================================================
1740
+ // Scaffold
1741
+ // =============================================================================
1742
+
1743
+ /// Write a minimal `.devcontainer/devcontainer.json` when the workspace
1744
+ /// doesn't have one yet, and open it for editing. The template is
1745
+ /// deliberately conservative — the user picks an image and tweaks
1746
+ /// lifecycle hooks from there. Matches the spec's "Configure Dev
1747
+ /// Container" entry for the Local branch of the Remote Indicator
1748
+ /// popup.
1749
+ function devcontainer_scaffold_config(): void {
1750
+ const cwd = editor.getCwd();
1751
+ const dcDir = editor.pathJoin(cwd, ".devcontainer");
1752
+ const configFile = editor.pathJoin(dcDir, "devcontainer.json");
1753
+
1754
+ // Respect an existing config — always a safer default than
1755
+ // overwriting. The user can call `devcontainer_open_config` if they
1756
+ // just meant to edit it.
1757
+ if (editor.fileExists(configFile)) {
1758
+ editor.setStatus(editor.t("status.scaffold_already_exists"));
1759
+ editor.openFile(configFile, null, null);
1760
+ return;
1761
+ }
1762
+
1763
+ if (!editor.createDir(dcDir)) {
1764
+ editor.setStatus(editor.t("status.scaffold_failed"));
1765
+ return;
1766
+ }
1767
+
1768
+ const workspaceName = cwd.split("/").filter(Boolean).pop() ?? "workspace";
1769
+ const template =
1770
+ JSON.stringify(
1771
+ {
1772
+ name: workspaceName,
1773
+ image: "mcr.microsoft.com/devcontainers/base:ubuntu",
1774
+ },
1775
+ null,
1776
+ 2,
1777
+ ) + "\n";
1778
+
1779
+ if (!editor.writeFile(configFile, template)) {
1780
+ editor.setStatus(editor.t("status.scaffold_failed"));
1781
+ return;
1782
+ }
1783
+
1784
+ // Refresh the in-memory config so a subsequent "Reopen in Container"
1785
+ // uses the new file without requiring a plugin reload.
1786
+ try {
1787
+ config = editor.parseJsonc(template) as DevContainerConfig;
1788
+ configPath = configFile;
1789
+ registerCommands();
1790
+ } catch (e) {
1791
+ editor.debug("devcontainer: scaffold parse failed: " + String(e));
1792
+ }
1793
+
1794
+ editor.setStatus(editor.t("status.scaffold_created"));
1795
+ editor.openFile(configFile, null, null);
1796
+ }
1797
+ registerHandler("devcontainer_scaffold_config", devcontainer_scaffold_config);
1798
+
1799
+ // =============================================================================
1800
+ // One-shot attach prompt
1801
+ // =============================================================================
1802
+ //
1803
+ // When the plugin loads and a devcontainer.json is found, check whether
1804
+ // we've already asked the user about this workspace. If not, surface a
1805
+ // one-shot "attach?" popup. The answer is remembered per-workspace via
1806
+ // plugin global state (keyed by cwd) so reopening the same project
1807
+ // doesn't re-prompt every time.
1808
+
1809
+ type AttachDecision = "attached" | "dismissed";
1810
+
1811
+ function attachDecisionKey(): string {
1812
+ return "attach:" + editor.getCwd();
1813
+ }
1814
+
1815
+ function readAttachDecision(): AttachDecision | null {
1816
+ const raw = editor.getGlobalState(attachDecisionKey()) as unknown;
1817
+ if (raw === "attached" || raw === "dismissed") return raw;
1818
+ return null;
1819
+ }
1820
+
1821
+ function writeAttachDecision(value: AttachDecision): void {
1822
+ editor.setGlobalState(attachDecisionKey(), value);
1823
+ }
1824
+
1825
+ /// Breadcrumb written before calling `editor.setAuthority(payload)`
1826
+ /// — setAuthority restarts the editor, so there's no clean callback
1827
+ /// to hook once the new authority is live. If the post-restart plugin
1828
+ /// instance sees this key with no matching container authority
1829
+ /// installed, the attach round-tripped through setAuthority but the
1830
+ /// core failed to construct the authority (rare: a rejected
1831
+ /// AuthorityPayload). We surface that as FailedAttach so users aren't
1832
+ /// stuck wondering why Connecting silently became Local.
1833
+ ///
1834
+ /// The key carries the epoch-ms timestamp of the attempt so stale
1835
+ /// entries from long-dormant sessions don't bleed into a fresh
1836
+ /// attach years later.
1837
+ function attachAttemptKey(): string {
1838
+ return "attach-attempt:" + editor.getCwd();
1839
+ }
1840
+
1841
+ function writeAttachAttempt(): void {
1842
+ editor.setGlobalState(attachAttemptKey(), String(Date.now()));
1843
+ }
1844
+
1845
+ function clearAttachAttempt(): void {
1846
+ editor.setGlobalState(attachAttemptKey(), null);
1847
+ }
1848
+
1849
+ function readAttachAttemptMs(): number | null {
1850
+ const raw = editor.getGlobalState(attachAttemptKey()) as unknown;
1851
+ if (typeof raw === "string") {
1852
+ const n = Number(raw);
1853
+ return Number.isFinite(n) ? n : null;
1854
+ }
1855
+ return null;
1856
+ }
1857
+
1858
+ function showAttachPrompt(): void {
1859
+ editor.showActionPopup({
1860
+ id: "devcontainer-attach",
1861
+ title: editor.t("popup.attach_title"),
1862
+ message: editor.t("popup.attach_message", {
1863
+ name: config?.name ?? "unnamed",
1864
+ }),
1865
+ actions: [
1866
+ { id: "attach", label: editor.t("popup.attach_action_attach") },
1867
+ { id: "dismiss", label: editor.t("popup.attach_action_dismiss") },
1868
+ ],
1869
+ });
1870
+ }
1871
+
1872
+ function devcontainer_on_attach_popup(data: ActionPopupResultData): void {
1873
+ if (data.popup_id !== "devcontainer-attach") return;
1874
+ if (data.action_id === "attach") {
1875
+ writeAttachDecision("attached");
1876
+ // Fire and forget: runDevcontainerUp's setAuthority call restarts
1877
+ // the editor, so nothing after this runs anyway.
1878
+ void devcontainer_attach();
1879
+ } else {
1880
+ writeAttachDecision("dismissed");
1881
+ }
1882
+ }
1883
+ registerHandler("devcontainer_on_attach_popup", devcontainer_on_attach_popup);
1884
+
1885
+ // =============================================================================
1886
+ // Event Handlers
1887
+ // =============================================================================
1888
+
1889
+ editor.on("prompt_confirmed", "devcontainer_on_lifecycle_confirmed");
1890
+ editor.on("action_popup_result", "devcontainer_on_action_result");
1891
+
1892
+ // =============================================================================
1893
+ // Command Registration
1894
+ // =============================================================================
1895
+
1896
+ function registerCommands(): void {
1897
+ editor.registerCommand(
1898
+ "%cmd.show_info",
1899
+ "%cmd.show_info_desc",
1900
+ "devcontainer_show_info",
1901
+ null,
1902
+ );
1903
+ editor.registerCommand(
1904
+ "%cmd.open_config",
1905
+ "%cmd.open_config_desc",
1906
+ "devcontainer_open_config",
1907
+ null,
1908
+ );
1909
+ editor.registerCommand(
1910
+ "%cmd.run_lifecycle",
1911
+ "%cmd.run_lifecycle_desc",
1912
+ "devcontainer_run_lifecycle",
1913
+ null,
1914
+ );
1915
+ editor.registerCommand(
1916
+ "%cmd.show_features",
1917
+ "%cmd.show_features_desc",
1918
+ "devcontainer_show_features",
1919
+ null,
1920
+ );
1921
+ editor.registerCommand(
1922
+ "%cmd.show_ports",
1923
+ "%cmd.show_ports_desc",
1924
+ "devcontainer_show_ports",
1925
+ null,
1926
+ );
1927
+ editor.registerCommand(
1928
+ "%cmd.rebuild",
1929
+ "%cmd.rebuild_desc",
1930
+ "devcontainer_rebuild",
1931
+ null,
1932
+ );
1933
+ editor.registerCommand(
1934
+ "%cmd.attach",
1935
+ "%cmd.attach_desc",
1936
+ "devcontainer_attach",
1937
+ null,
1938
+ );
1939
+ editor.registerCommand(
1940
+ "%cmd.detach",
1941
+ "%cmd.detach_desc",
1942
+ "devcontainer_detach",
1943
+ null,
1944
+ );
1945
+ editor.registerCommand(
1946
+ "%cmd.show_logs",
1947
+ "%cmd.show_logs_desc",
1948
+ "devcontainer_show_logs",
1949
+ null,
1950
+ );
1951
+ editor.registerCommand(
1952
+ "%cmd.show_build_logs",
1953
+ "%cmd.show_build_logs_desc",
1954
+ "devcontainer_show_build_logs",
1955
+ null,
1956
+ );
1957
+ editor.registerCommand(
1958
+ "%cmd.cancel_attach",
1959
+ "%cmd.cancel_attach_desc",
1960
+ "devcontainer_cancel_attach",
1961
+ null,
1962
+ );
1963
+ editor.registerCommand(
1964
+ "%cmd.show_forwarded_ports_panel",
1965
+ "%cmd.show_forwarded_ports_panel_desc",
1966
+ "devcontainer_show_forwarded_ports_panel",
1967
+ null,
1968
+ );
1969
+ }
1970
+
1971
+ // =============================================================================
1972
+ // Initialization
1973
+ // =============================================================================
1974
+
1975
+ // The scaffold command is the only palette entry that makes sense
1976
+ // without a detected config — it's how the user creates one. Register
1977
+ // unconditionally so "Dev Container: Create Config" is reachable from
1978
+ // a cold workspace.
1979
+ editor.registerCommand(
1980
+ "%cmd.scaffold_config",
1981
+ "%cmd.scaffold_config_desc",
1982
+ "devcontainer_scaffold_config",
1983
+ null,
1984
+ );
1985
+
1986
+ if (findConfig()) {
1987
+ registerCommands();
1988
+
1989
+ const name = config!.name ?? "unnamed";
1990
+ const image = getImageSummary();
1991
+ const featureCount = config!.features ? Object.keys(config!.features).length : 0;
1992
+ const portCount = config!.forwardPorts?.length ?? 0;
1993
+
1994
+ editor.setStatus(
1995
+ editor.t("status.detected", {
1996
+ name,
1997
+ image,
1998
+ features: String(featureCount),
1999
+ ports: String(portCount),
2000
+ }),
2001
+ );
2002
+
2003
+ editor.debug("Dev Container plugin initialized: " + name);
2004
+
2005
+ // Decide whether to surface the attach prompt AFTER main.rs installs
2006
+ // the boot authority. When the plugin's top-level body runs, the
2007
+ // editor is still being constructed and `authority.display_label` is
2008
+ // whatever the default Authority carried during Editor construction —
2009
+ // which is empty even on the post-attach restart, because the real
2010
+ // container authority is only installed via `set_boot_authority`
2011
+ // (called right before `plugins_loaded` fires). Deferring to this
2012
+ // hook means `getAuthorityLabel()` reads the freshly-refreshed
2013
+ // snapshot and we don't re-prompt a user who already attached.
2014
+ function devcontainer_maybe_show_attach_prompt(): void {
2015
+ const authorityLabel = editor.getAuthorityLabel();
2016
+ const alreadyAttached = authorityLabel.length > 0;
2017
+
2018
+ // Post-restart recovery: clear or surface a FailedAttach for
2019
+ // attempts that round-tripped through setAuthority without
2020
+ // landing a container. Stale breadcrumbs (> 30 min) are
2021
+ // quietly dropped so an old attempt can't poison a fresh
2022
+ // session years later.
2023
+ const attemptMs = readAttachAttemptMs();
2024
+ if (attemptMs !== null) {
2025
+ const ageMs = Date.now() - attemptMs;
2026
+ const MAX_AGE_MS = 30 * 60 * 1000;
2027
+ if (ageMs > MAX_AGE_MS) {
2028
+ clearAttachAttempt();
2029
+ } else if (alreadyAttached) {
2030
+ // Matching container authority came up — success path.
2031
+ clearAttachAttempt();
2032
+ } else {
2033
+ // No container landed but we just tried. Surface it with the
2034
+ // same proactive popup as an in-flight failure so users see
2035
+ // Retry / Reopen Locally without having to click the
2036
+ // indicator.
2037
+ enterFailedAttach(editor.t("indicator.error_restart_recovery"));
2038
+ clearAttachAttempt();
2039
+ // Do not also show the attach prompt — the failed-attach
2040
+ // popup is the right next surface; stacking a second popup
2041
+ // on top would bury it.
2042
+ return;
2043
+ }
2044
+ }
2045
+
2046
+ if (alreadyAttached) {
2047
+ editor.debug(
2048
+ "Dev Container plugin: authority '" + authorityLabel + "' already installed, skipping attach prompt",
2049
+ );
2050
+ return;
2051
+ }
2052
+ // One-shot per-session dismissal: if the user already said "Not
2053
+ // now" in this Editor process, don't re-prompt. On a cold restart
2054
+ // the state is gone and we ask again — that's fine.
2055
+ const previousDecision = readAttachDecision();
2056
+ if (previousDecision !== null) return;
2057
+ showAttachPrompt();
2058
+ }
2059
+ registerHandler(
2060
+ "devcontainer_maybe_show_attach_prompt",
2061
+ devcontainer_maybe_show_attach_prompt,
2062
+ );
2063
+ editor.on("plugins_loaded", "devcontainer_maybe_show_attach_prompt");
2064
+ } else {
2065
+ editor.debug("Dev Container plugin: no devcontainer.json found");
2066
+ }