@fresh-editor/fresh-editor 0.3.0 → 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 +66 -2
- package/package.json +1 -1
- package/plugins/astro-lsp.ts +6 -12
- package/plugins/audit_mode.ts +106 -113
- 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 +10 -0
- 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 +118 -0
- package/plugins/devcontainer.i18n.json +84 -28
- package/plugins/devcontainer.ts +897 -170
- 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.ts +8 -8
- 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 +378 -0
- package/plugins/lua-lsp.ts +15 -22
- package/plugins/markdown_compose.ts +78 -122
- 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 +18 -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 +70 -14
- package/plugins/theme_editor.ts +152 -208
- 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/plugins/devcontainer.ts
CHANGED
|
@@ -33,6 +33,7 @@ interface DevContainerConfig {
|
|
|
33
33
|
appPort?: number | string | (number | string)[];
|
|
34
34
|
containerEnv?: Record<string, string>;
|
|
35
35
|
remoteEnv?: Record<string, string>;
|
|
36
|
+
userEnvProbe?: "none" | "loginShell" | "loginInteractiveShell" | "interactiveShell";
|
|
36
37
|
containerUser?: string;
|
|
37
38
|
remoteUser?: string;
|
|
38
39
|
mounts?: (string | MountConfig)[];
|
|
@@ -87,6 +88,29 @@ let infoPanelSplitId: number | null = null;
|
|
|
87
88
|
let infoPanelOpen = false;
|
|
88
89
|
let cachedContent = "";
|
|
89
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
|
+
|
|
90
114
|
// The in-flight `devcontainer up` handle (set before we await, cleared
|
|
91
115
|
// on exit). `devcontainer_cancel_attach` forwards `.kill()` to this.
|
|
92
116
|
// null when no attach is running.
|
|
@@ -136,33 +160,47 @@ const colors = {
|
|
|
136
160
|
// Config Discovery
|
|
137
161
|
// =============================================================================
|
|
138
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
|
+
|
|
139
188
|
function findConfig(): boolean {
|
|
140
189
|
const cwd = editor.getCwd();
|
|
190
|
+
lastParseError = null;
|
|
141
191
|
|
|
142
192
|
// Priority 1: .devcontainer/devcontainer.json
|
|
143
193
|
const primary = editor.pathJoin(cwd, ".devcontainer", "devcontainer.json");
|
|
144
194
|
const primaryContent = editor.readFile(primary);
|
|
145
195
|
if (primaryContent !== null) {
|
|
146
|
-
|
|
147
|
-
config = editor.parseJsonc(primaryContent) as DevContainerConfig;
|
|
148
|
-
configPath = primary;
|
|
149
|
-
return true;
|
|
150
|
-
} catch {
|
|
151
|
-
editor.debug("devcontainer: failed to parse " + primary);
|
|
152
|
-
}
|
|
196
|
+
if (tryParse(primary, primaryContent)) return true;
|
|
153
197
|
}
|
|
154
198
|
|
|
155
199
|
// Priority 2: .devcontainer.json
|
|
156
200
|
const secondary = editor.pathJoin(cwd, ".devcontainer.json");
|
|
157
201
|
const secondaryContent = editor.readFile(secondary);
|
|
158
202
|
if (secondaryContent !== null) {
|
|
159
|
-
|
|
160
|
-
config = editor.parseJsonc(secondaryContent) as DevContainerConfig;
|
|
161
|
-
configPath = secondary;
|
|
162
|
-
return true;
|
|
163
|
-
} catch {
|
|
164
|
-
editor.debug("devcontainer: failed to parse " + secondary);
|
|
165
|
-
}
|
|
203
|
+
if (tryParse(secondary, secondaryContent)) return true;
|
|
166
204
|
}
|
|
167
205
|
|
|
168
206
|
// Priority 3: .devcontainer/<subfolder>/devcontainer.json
|
|
@@ -174,13 +212,7 @@ function findConfig(): boolean {
|
|
|
174
212
|
const subConfig = editor.pathJoin(dcDir, entry.name, "devcontainer.json");
|
|
175
213
|
const subContent = editor.readFile(subConfig);
|
|
176
214
|
if (subContent !== null) {
|
|
177
|
-
|
|
178
|
-
config = editor.parseJsonc(subContent) as DevContainerConfig;
|
|
179
|
-
configPath = subConfig;
|
|
180
|
-
return true;
|
|
181
|
-
} catch {
|
|
182
|
-
editor.debug("devcontainer: failed to parse " + subConfig);
|
|
183
|
-
}
|
|
215
|
+
if (tryParse(subConfig, subContent)) return true;
|
|
184
216
|
}
|
|
185
217
|
}
|
|
186
218
|
}
|
|
@@ -189,6 +221,21 @@ function findConfig(): boolean {
|
|
|
189
221
|
return false;
|
|
190
222
|
}
|
|
191
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
|
+
|
|
192
239
|
// =============================================================================
|
|
193
240
|
// Formatting Helpers
|
|
194
241
|
// =============================================================================
|
|
@@ -581,27 +628,21 @@ async function devcontainer_show_info(): Promise<void> {
|
|
|
581
628
|
return;
|
|
582
629
|
}
|
|
583
630
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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.
|
|
590
638
|
infoFocus = { type: "button", index: 0 };
|
|
591
639
|
const entries = buildInfoEntries();
|
|
592
640
|
cachedContent = entriesToContent(entries);
|
|
593
641
|
|
|
594
|
-
const result = await
|
|
642
|
+
const result = await openVirtualInPanelSlot({
|
|
595
643
|
name: "*Dev Container*",
|
|
596
644
|
mode: "devcontainer-info",
|
|
597
|
-
|
|
598
|
-
showLineNumbers: false,
|
|
599
|
-
showCursors: true,
|
|
600
|
-
editingDisabled: true,
|
|
601
|
-
lineWrap: true,
|
|
602
|
-
ratio: 0.4,
|
|
603
|
-
direction: "horizontal",
|
|
604
|
-
entries: entries,
|
|
645
|
+
entries,
|
|
605
646
|
});
|
|
606
647
|
|
|
607
648
|
if (result !== null) {
|
|
@@ -677,57 +718,358 @@ function devcontainer_run_lifecycle(): void {
|
|
|
677
718
|
}
|
|
678
719
|
registerHandler("devcontainer_run_lifecycle", devcontainer_run_lifecycle);
|
|
679
720
|
|
|
680
|
-
async function devcontainer_on_lifecycle_confirmed(data: {
|
|
681
|
-
prompt_type: string;
|
|
682
|
-
value: string;
|
|
683
|
-
}): Promise<void> {
|
|
684
|
-
if (data.prompt_type !== "devcontainer-lifecycle") return;
|
|
685
721
|
|
|
686
|
-
const cmdName = data.value;
|
|
687
|
-
if (!config || !cmdName) return;
|
|
688
722
|
|
|
689
|
-
|
|
690
|
-
|
|
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
|
+
}
|
|
691
759
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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;
|
|
699
824
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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);
|
|
708
877
|
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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;
|
|
715
1019
|
if (Array.isArray(subcmd)) {
|
|
716
|
-
[
|
|
1020
|
+
[origBin, ...origArgs] = subcmd;
|
|
1021
|
+
cmdline = [origBin, ...origArgs].join(" ");
|
|
717
1022
|
} else {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const result = await editor.spawnProcess(bin, args, editor.getCwd());
|
|
722
|
-
if (result.exit_code !== 0) {
|
|
723
|
-
editor.setStatus(editor.t("status.failed_sub", { name: cmdName, label, code: String(result.exit_code) }));
|
|
724
|
-
return;
|
|
1023
|
+
origBin = "sh";
|
|
1024
|
+
origArgs = ["-c", subcmd as string];
|
|
1025
|
+
cmdline = subcmd as string;
|
|
725
1026
|
}
|
|
726
|
-
|
|
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) {
|
|
727
1058
|
editor.setStatus(editor.t("status.completed", { name: cmdName }));
|
|
1059
|
+
return;
|
|
728
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
|
+
);
|
|
729
1072
|
}
|
|
730
|
-
registerHandler("devcontainer_on_lifecycle_confirmed", devcontainer_on_lifecycle_confirmed);
|
|
731
1073
|
|
|
732
1074
|
function devcontainer_show_features(): void {
|
|
733
1075
|
if (!config || !config.features || Object.keys(config.features).length === 0) {
|
|
@@ -1044,23 +1386,15 @@ async function devcontainer_show_forwarded_ports_panel(): Promise<void> {
|
|
|
1044
1386
|
return;
|
|
1045
1387
|
}
|
|
1046
1388
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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`).
|
|
1052
1393
|
const rows = await gatherForwardedPortRows();
|
|
1053
1394
|
const entries = buildPortsPanelEntries(rows);
|
|
1054
|
-
const result = await
|
|
1395
|
+
const result = await openVirtualInPanelSlot({
|
|
1055
1396
|
name: "*Dev Container Ports*",
|
|
1056
1397
|
mode: "devcontainer-ports",
|
|
1057
|
-
readOnly: true,
|
|
1058
|
-
showLineNumbers: false,
|
|
1059
|
-
showCursors: true,
|
|
1060
|
-
editingDisabled: true,
|
|
1061
|
-
lineWrap: true,
|
|
1062
|
-
ratio: 0.35,
|
|
1063
|
-
direction: "horizontal",
|
|
1064
1398
|
entries,
|
|
1065
1399
|
});
|
|
1066
1400
|
if (result !== null) {
|
|
@@ -1133,28 +1467,7 @@ function showCliNotFoundPopup(): void {
|
|
|
1133
1467
|
});
|
|
1134
1468
|
}
|
|
1135
1469
|
|
|
1136
|
-
|
|
1137
|
-
if (data.popup_id === "devcontainer-cli-help") {
|
|
1138
|
-
switch (data.action_id) {
|
|
1139
|
-
case "copy_install":
|
|
1140
|
-
editor.setClipboard(INSTALL_COMMAND);
|
|
1141
|
-
editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
|
|
1142
|
-
break;
|
|
1143
|
-
case "dismiss":
|
|
1144
|
-
case "dismissed":
|
|
1145
|
-
break;
|
|
1146
|
-
}
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
if (data.popup_id === "devcontainer-attach") {
|
|
1150
|
-
devcontainer_on_attach_popup(data);
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
if (data.popup_id === "devcontainer-failed-attach") {
|
|
1154
|
-
devcontainer_on_failed_attach_popup(data);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
registerHandler("devcontainer_on_action_result", devcontainer_on_action_result);
|
|
1470
|
+
|
|
1158
1471
|
|
|
1159
1472
|
/// Surface a proactive action popup after a failed attach so users
|
|
1160
1473
|
/// don't have to notice the Remote Indicator's red state on their own.
|
|
@@ -1266,6 +1579,8 @@ function parseDevcontainerUpOutput(stdout: string): DevcontainerUpResult | null
|
|
|
1266
1579
|
|
|
1267
1580
|
function buildContainerAuthorityPayload(
|
|
1268
1581
|
result: DevcontainerUpResult,
|
|
1582
|
+
baseEnv: Array<[string, string]>,
|
|
1583
|
+
hostWorkspace: string | null,
|
|
1269
1584
|
): AuthorityPayload | null {
|
|
1270
1585
|
if (!result.containerId) return null;
|
|
1271
1586
|
const user = result.remoteUser ?? null;
|
|
@@ -1282,6 +1597,20 @@ function buildContainerAuthorityPayload(
|
|
|
1282
1597
|
|
|
1283
1598
|
const shortId = result.containerId.slice(0, 12);
|
|
1284
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
|
+
|
|
1285
1614
|
return {
|
|
1286
1615
|
filesystem: { kind: "local" },
|
|
1287
1616
|
spawner: {
|
|
@@ -1289,6 +1618,7 @@ function buildContainerAuthorityPayload(
|
|
|
1289
1618
|
container_id: result.containerId,
|
|
1290
1619
|
user,
|
|
1291
1620
|
workspace,
|
|
1621
|
+
env: baseEnv,
|
|
1292
1622
|
},
|
|
1293
1623
|
terminal_wrapper: {
|
|
1294
1624
|
kind: "explicit",
|
|
@@ -1297,6 +1627,7 @@ function buildContainerAuthorityPayload(
|
|
|
1297
1627
|
manages_cwd: true,
|
|
1298
1628
|
},
|
|
1299
1629
|
display_label: "Container:" + shortId,
|
|
1630
|
+
path_translation,
|
|
1300
1631
|
};
|
|
1301
1632
|
}
|
|
1302
1633
|
|
|
@@ -1425,6 +1756,11 @@ async function runDevcontainerUp(extraArgs: string[]): Promise<void> {
|
|
|
1425
1756
|
return;
|
|
1426
1757
|
}
|
|
1427
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);
|
|
1428
1764
|
// Open the log in a split below so the user sees lines stream in
|
|
1429
1765
|
// (auto-revert polls every 2s) without losing the buffer they were
|
|
1430
1766
|
// editing. `split_horizontal` duplicates the current buffer into a
|
|
@@ -1483,7 +1819,22 @@ async function runDevcontainerUp(extraArgs: string[]): Promise<void> {
|
|
|
1483
1819
|
return;
|
|
1484
1820
|
}
|
|
1485
1821
|
|
|
1486
|
-
|
|
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);
|
|
1487
1838
|
if (!payload) {
|
|
1488
1839
|
enterFailedAttach(editor.t("status.rebuild_missing_container_id"));
|
|
1489
1840
|
return;
|
|
@@ -1498,6 +1849,13 @@ async function runDevcontainerUp(extraArgs: string[]): Promise<void> {
|
|
|
1498
1849
|
// decide between success (container authority live) and silent
|
|
1499
1850
|
// failure (no authority landed — surfaces as FailedAttach).
|
|
1500
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);
|
|
1501
1859
|
editor.setAuthority(payload);
|
|
1502
1860
|
}
|
|
1503
1861
|
|
|
@@ -1552,19 +1910,138 @@ function lastBuildLogKey(): string {
|
|
|
1552
1910
|
/// rebuilds the editor and workspace restore brings the log buffer
|
|
1553
1911
|
/// back, the first `Show Build Logs` finds the restored split and
|
|
1554
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
|
+
|
|
1555
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");
|
|
1556
2039
|
const buffers = editor.listBuffers();
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2040
|
+
for (const b of buffers) {
|
|
2041
|
+
if (b.path && b.path.startsWith(prefix)) {
|
|
2042
|
+
editor.closeBuffer(b.id);
|
|
2043
|
+
}
|
|
1561
2044
|
}
|
|
1562
|
-
// Not visible anywhere → create a new split and open the log
|
|
1563
|
-
// there. openFile reuses the buffer when the path is already
|
|
1564
|
-
// loaded (e.g. open but not in any split), so no duplicate
|
|
1565
|
-
// buffers either way.
|
|
1566
|
-
editor.executeAction("split_horizontal");
|
|
1567
|
-
editor.openFile(path, null, null);
|
|
1568
2045
|
}
|
|
1569
2046
|
|
|
1570
2047
|
function rememberLastBuildLogPath(path: string): void {
|
|
@@ -1620,10 +2097,48 @@ async function devcontainer_retry_attach(): Promise<void> {
|
|
|
1620
2097
|
registerHandler("devcontainer_retry_attach", devcontainer_retry_attach);
|
|
1621
2098
|
|
|
1622
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();
|
|
1623
2108
|
editor.clearAuthority();
|
|
1624
2109
|
}
|
|
1625
2110
|
registerHandler("devcontainer_detach", devcontainer_detach);
|
|
1626
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
|
+
|
|
1627
2142
|
/// Abort an in-flight attach by killing the `devcontainer up` host
|
|
1628
2143
|
/// spawn. No-op when nothing is in flight. The indicator is flipped
|
|
1629
2144
|
/// back to Local immediately — cancel is a user-initiated revert,
|
|
@@ -1718,16 +2233,11 @@ async function devcontainer_show_logs(): Promise<void> {
|
|
|
1718
2233
|
? mergedParts.join("\n")
|
|
1719
2234
|
: editor.t("status.logs_empty");
|
|
1720
2235
|
|
|
1721
|
-
|
|
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({
|
|
1722
2239
|
name: "*Dev Container Logs*",
|
|
1723
2240
|
mode: "devcontainer-info",
|
|
1724
|
-
readOnly: true,
|
|
1725
|
-
showLineNumbers: false,
|
|
1726
|
-
showCursors: true,
|
|
1727
|
-
editingDisabled: true,
|
|
1728
|
-
lineWrap: true,
|
|
1729
|
-
ratio: 0.4,
|
|
1730
|
-
direction: "horizontal",
|
|
1731
2241
|
entries: [{ text: merged, properties: { type: "log" } }],
|
|
1732
2242
|
});
|
|
1733
2243
|
if (result !== null) {
|
|
@@ -1822,6 +2332,14 @@ function writeAttachDecision(value: AttachDecision): void {
|
|
|
1822
2332
|
editor.setGlobalState(attachDecisionKey(), value);
|
|
1823
2333
|
}
|
|
1824
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
|
+
|
|
1825
2343
|
/// Breadcrumb written before calling `editor.setAuthority(payload)`
|
|
1826
2344
|
/// — setAuthority restarts the editor, so there's no clean callback
|
|
1827
2345
|
/// to hook once the new authority is live. If the post-restart plugin
|
|
@@ -1864,7 +2382,8 @@ function showAttachPrompt(): void {
|
|
|
1864
2382
|
}),
|
|
1865
2383
|
actions: [
|
|
1866
2384
|
{ id: "attach", label: editor.t("popup.attach_action_attach") },
|
|
1867
|
-
{ id: "
|
|
2385
|
+
{ id: "dismiss_once", label: editor.t("popup.attach_action_dismiss_once") },
|
|
2386
|
+
{ id: "dismiss_always", label: editor.t("popup.attach_action_dismiss_always") },
|
|
1868
2387
|
],
|
|
1869
2388
|
});
|
|
1870
2389
|
}
|
|
@@ -1876,8 +2395,16 @@ function devcontainer_on_attach_popup(data: ActionPopupResultData): void {
|
|
|
1876
2395
|
// Fire and forget: runDevcontainerUp's setAuthority call restarts
|
|
1877
2396
|
// the editor, so nothing after this runs anyway.
|
|
1878
2397
|
void devcontainer_attach();
|
|
1879
|
-
} else {
|
|
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.
|
|
1880
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;
|
|
1881
2408
|
}
|
|
1882
2409
|
}
|
|
1883
2410
|
registerHandler("devcontainer_on_attach_popup", devcontainer_on_attach_popup);
|
|
@@ -1886,14 +2413,192 @@ registerHandler("devcontainer_on_attach_popup", devcontainer_on_attach_popup);
|
|
|
1886
2413
|
// Event Handlers
|
|
1887
2414
|
// =============================================================================
|
|
1888
2415
|
|
|
1889
|
-
editor.on("prompt_confirmed",
|
|
1890
|
-
|
|
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
|
+
}
|
|
1891
2527
|
|
|
1892
2528
|
// =============================================================================
|
|
1893
2529
|
// Command Registration
|
|
1894
2530
|
// =============================================================================
|
|
1895
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
|
+
|
|
1896
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);
|
|
1897
2602
|
editor.registerCommand(
|
|
1898
2603
|
"%cmd.show_info",
|
|
1899
2604
|
"%cmd.show_info_desc",
|
|
@@ -1906,12 +2611,6 @@ function registerCommands(): void {
|
|
|
1906
2611
|
"devcontainer_open_config",
|
|
1907
2612
|
null,
|
|
1908
2613
|
);
|
|
1909
|
-
editor.registerCommand(
|
|
1910
|
-
"%cmd.run_lifecycle",
|
|
1911
|
-
"%cmd.run_lifecycle_desc",
|
|
1912
|
-
"devcontainer_run_lifecycle",
|
|
1913
|
-
null,
|
|
1914
|
-
);
|
|
1915
2614
|
editor.registerCommand(
|
|
1916
2615
|
"%cmd.show_features",
|
|
1917
2616
|
"%cmd.show_features_desc",
|
|
@@ -1930,42 +2629,58 @@ function registerCommands(): void {
|
|
|
1930
2629
|
"devcontainer_rebuild",
|
|
1931
2630
|
null,
|
|
1932
2631
|
);
|
|
1933
|
-
editor.registerCommand(
|
|
1934
|
-
"%cmd.attach",
|
|
1935
|
-
"%cmd.attach_desc",
|
|
1936
|
-
"devcontainer_attach",
|
|
1937
|
-
null,
|
|
1938
|
-
);
|
|
1939
|
-
editor.registerCommand(
|
|
1940
|
-
"%cmd.detach",
|
|
1941
|
-
"%cmd.detach_desc",
|
|
1942
|
-
"devcontainer_detach",
|
|
1943
|
-
null,
|
|
1944
|
-
);
|
|
1945
|
-
editor.registerCommand(
|
|
1946
|
-
"%cmd.show_logs",
|
|
1947
|
-
"%cmd.show_logs_desc",
|
|
1948
|
-
"devcontainer_show_logs",
|
|
1949
|
-
null,
|
|
1950
|
-
);
|
|
1951
2632
|
editor.registerCommand(
|
|
1952
2633
|
"%cmd.show_build_logs",
|
|
1953
2634
|
"%cmd.show_build_logs_desc",
|
|
1954
2635
|
"devcontainer_show_build_logs",
|
|
1955
2636
|
null,
|
|
1956
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.
|
|
1957
2641
|
editor.registerCommand(
|
|
1958
|
-
"%cmd.
|
|
1959
|
-
"%cmd.
|
|
1960
|
-
"
|
|
2642
|
+
"%cmd.run_lifecycle",
|
|
2643
|
+
"%cmd.run_lifecycle_desc",
|
|
2644
|
+
"devcontainer_run_lifecycle",
|
|
1961
2645
|
null,
|
|
1962
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.)
|
|
1963
2651
|
editor.registerCommand(
|
|
1964
2652
|
"%cmd.show_forwarded_ports_panel",
|
|
1965
2653
|
"%cmd.show_forwarded_ports_panel_desc",
|
|
1966
2654
|
"devcontainer_show_forwarded_ports_panel",
|
|
1967
2655
|
null,
|
|
1968
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
|
+
}
|
|
1969
2684
|
}
|
|
1970
2685
|
|
|
1971
2686
|
// =============================================================================
|
|
@@ -2049,18 +2764,30 @@ if (findConfig()) {
|
|
|
2049
2764
|
);
|
|
2050
2765
|
return;
|
|
2051
2766
|
}
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
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.
|
|
2055
2770
|
const previousDecision = readAttachDecision();
|
|
2056
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;
|
|
2057
2776
|
showAttachPrompt();
|
|
2058
2777
|
}
|
|
2059
|
-
|
|
2060
|
-
"devcontainer_maybe_show_attach_prompt",
|
|
2061
|
-
devcontainer_maybe_show_attach_prompt,
|
|
2062
|
-
);
|
|
2063
|
-
editor.on("plugins_loaded", "devcontainer_maybe_show_attach_prompt");
|
|
2778
|
+
editor.on("plugins_loaded", devcontainer_maybe_show_attach_prompt);
|
|
2064
2779
|
} else {
|
|
2065
|
-
|
|
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
|
+
}
|
|
2066
2793
|
}
|