@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.
- package/CHANGELOG.md +152 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +14 -14
- package/plugins/audit_mode.ts +76 -33
- package/plugins/config-schema.json +69 -6
- package/plugins/dashboard.ts +1785 -0
- package/plugins/devcontainer.i18n.json +1416 -0
- package/plugins/devcontainer.ts +2066 -0
- package/plugins/git_log.i18n.json +14 -42
- package/plugins/git_log.ts +11 -1
- package/plugins/lib/fresh.d.ts +225 -0
- package/plugins/markdown_compose.ts +54 -6
- package/plugins/schemas/theme.schema.json +108 -0
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/theme_editor.ts +4 -1
- package/themes/high-contrast.json +2 -2
- package/themes/nord.json +4 -0
- package/themes/solarized-dark.json +4 -0
|
@@ -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
|
+
}
|