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