@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +66 -2
  2. package/package.json +1 -1
  3. package/plugins/astro-lsp.ts +6 -12
  4. package/plugins/audit_mode.ts +106 -113
  5. package/plugins/bash-lsp.ts +15 -22
  6. package/plugins/clangd-lsp.ts +15 -24
  7. package/plugins/clojure-lsp.ts +9 -12
  8. package/plugins/cmake-lsp.ts +9 -12
  9. package/plugins/code-tour.ts +15 -16
  10. package/plugins/config-schema.json +10 -0
  11. package/plugins/csharp_support.ts +25 -30
  12. package/plugins/css-lsp.ts +15 -22
  13. package/plugins/dart-lsp.ts +9 -12
  14. package/plugins/dashboard.ts +118 -0
  15. package/plugins/devcontainer.i18n.json +84 -28
  16. package/plugins/devcontainer.ts +897 -170
  17. package/plugins/diagnostics_panel.ts +10 -17
  18. package/plugins/elixir-lsp.ts +9 -12
  19. package/plugins/erlang-lsp.ts +9 -12
  20. package/plugins/examples/bookmarks.ts +10 -16
  21. package/plugins/find_references.ts +5 -9
  22. package/plugins/flash.ts +577 -0
  23. package/plugins/fsharp-lsp.ts +9 -12
  24. package/plugins/git_explorer.ts +16 -20
  25. package/plugins/git_gutter.ts +65 -79
  26. package/plugins/git_log.ts +8 -8
  27. package/plugins/gleam-lsp.ts +9 -12
  28. package/plugins/go-lsp.ts +15 -22
  29. package/plugins/graphql-lsp.ts +9 -12
  30. package/plugins/haskell-lsp.ts +9 -12
  31. package/plugins/html-lsp.ts +15 -24
  32. package/plugins/java-lsp.ts +9 -12
  33. package/plugins/json-lsp.ts +15 -24
  34. package/plugins/julia-lsp.ts +9 -12
  35. package/plugins/kotlin-lsp.ts +15 -22
  36. package/plugins/latex-lsp.ts +9 -12
  37. package/plugins/lib/fresh.d.ts +378 -0
  38. package/plugins/lua-lsp.ts +15 -22
  39. package/plugins/markdown_compose.ts +78 -122
  40. package/plugins/markdown_source.ts +8 -10
  41. package/plugins/marksman-lsp.ts +9 -12
  42. package/plugins/merge_conflict.ts +15 -17
  43. package/plugins/nim-lsp.ts +9 -12
  44. package/plugins/nix-lsp.ts +9 -12
  45. package/plugins/nushell-lsp.ts +9 -12
  46. package/plugins/ocaml-lsp.ts +9 -12
  47. package/plugins/odin-lsp.ts +15 -22
  48. package/plugins/path_complete.ts +5 -6
  49. package/plugins/perl-lsp.ts +9 -12
  50. package/plugins/php-lsp.ts +15 -22
  51. package/plugins/pkg.ts +10 -21
  52. package/plugins/protobuf-lsp.ts +9 -12
  53. package/plugins/python-lsp.ts +15 -24
  54. package/plugins/r-lsp.ts +9 -12
  55. package/plugins/ruby-lsp.ts +15 -22
  56. package/plugins/rust-lsp.ts +18 -28
  57. package/plugins/scala-lsp.ts +9 -12
  58. package/plugins/schemas/theme.schema.json +18 -0
  59. package/plugins/search_replace.ts +10 -13
  60. package/plugins/solidity-lsp.ts +9 -12
  61. package/plugins/sql-lsp.ts +9 -12
  62. package/plugins/svelte-lsp.ts +9 -12
  63. package/plugins/swift-lsp.ts +9 -12
  64. package/plugins/tailwindcss-lsp.ts +9 -12
  65. package/plugins/templ-lsp.ts +9 -12
  66. package/plugins/terraform-lsp.ts +9 -12
  67. package/plugins/theme_editor.i18n.json +70 -14
  68. package/plugins/theme_editor.ts +152 -208
  69. package/plugins/toml-lsp.ts +15 -22
  70. package/plugins/tsconfig.json +100 -0
  71. package/plugins/typescript-lsp.ts +15 -24
  72. package/plugins/typst-lsp.ts +15 -22
  73. package/plugins/vi_mode.ts +77 -290
  74. package/plugins/vue-lsp.ts +9 -12
  75. package/plugins/yaml-lsp.ts +15 -22
  76. package/plugins/zig-lsp.ts +9 -12
@@ -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
- try {
147
- config = editor.parseJsonc(primaryContent) as DevContainerConfig;
148
- configPath = primary;
149
- return true;
150
- } catch {
151
- editor.debug("devcontainer: failed to parse " + primary);
152
- }
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
- try {
160
- config = editor.parseJsonc(secondaryContent) as DevContainerConfig;
161
- configPath = secondary;
162
- return true;
163
- } catch {
164
- editor.debug("devcontainer: failed to parse " + secondary);
165
- }
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
- try {
178
- config = editor.parseJsonc(subContent) as DevContainerConfig;
179
- configPath = subConfig;
180
- return true;
181
- } catch {
182
- editor.debug("devcontainer: failed to parse " + subConfig);
183
- }
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
- if (infoPanelOpen && infoPanelBufferId !== null) {
585
- // Already open - refresh content
586
- updateInfoPanel();
587
- return;
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 editor.createVirtualBufferInSplit({
642
+ const result = await openVirtualInPanelSlot({
595
643
  name: "*Dev Container*",
596
644
  mode: "devcontainer-info",
597
- readOnly: true,
598
- showLineNumbers: false,
599
- showCursors: true,
600
- editingDisabled: true,
601
- lineWrap: true,
602
- ratio: 0.4,
603
- direction: "horizontal",
604
- entries: entries,
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
- const cmd = (config as Record<string, unknown>)[cmdName] as LifecycleCommand | undefined;
690
- if (!cmd) return;
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
- if (typeof cmd === "string") {
693
- editor.setStatus(editor.t("status.running", { name: cmdName }));
694
- const result = await editor.spawnProcess("sh", ["-c", cmd], editor.getCwd());
695
- if (result.exit_code === 0) {
696
- editor.setStatus(editor.t("status.completed", { name: cmdName }));
697
- } else {
698
- editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
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
- } else if (Array.isArray(cmd)) {
701
- const [bin, ...args] = cmd;
702
- editor.setStatus(editor.t("status.running", { name: cmdName }));
703
- const result = await editor.spawnProcess(bin, args, editor.getCwd());
704
- if (result.exit_code === 0) {
705
- editor.setStatus(editor.t("status.completed", { name: cmdName }));
706
- } else {
707
- editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
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
- } else {
710
- // Object form: run each named sub-command sequentially
711
- for (const [label, subcmd] of Object.entries(cmd)) {
712
- editor.setStatus(editor.t("status.running_sub", { name: cmdName, label }));
713
- let bin: string;
714
- let args: string[];
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
- [bin, ...args] = subcmd;
1020
+ [origBin, ...origArgs] = subcmd;
1021
+ cmdline = [origBin, ...origArgs].join(" ");
717
1022
  } else {
718
- bin = "sh";
719
- args = ["-c", subcmd as string];
720
- }
721
- const result = await editor.spawnProcess(bin, args, editor.getCwd());
722
- if (result.exit_code !== 0) {
723
- editor.setStatus(editor.t("status.failed_sub", { name: cmdName, label, code: String(result.exit_code) }));
724
- return;
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
- if (portsPanelOpen && portsPanelBufferId !== null) {
1048
- await renderPortsPanel();
1049
- return;
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 editor.createVirtualBufferInSplit({
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
- function devcontainer_on_action_result(data: ActionPopupResultData): void {
1137
- if (data.popup_id === "devcontainer-cli-help") {
1138
- switch (data.action_id) {
1139
- case "copy_install":
1140
- editor.setClipboard(INSTALL_COMMAND);
1141
- editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
1142
- break;
1143
- case "dismiss":
1144
- case "dismissed":
1145
- break;
1146
- }
1147
- return;
1148
- }
1149
- if (data.popup_id === "devcontainer-attach") {
1150
- devcontainer_on_attach_popup(data);
1151
- return;
1152
- }
1153
- if (data.popup_id === "devcontainer-failed-attach") {
1154
- devcontainer_on_failed_attach_popup(data);
1155
- }
1156
- }
1157
- registerHandler("devcontainer_on_action_result", devcontainer_on_action_result);
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
- const payload = buildContainerAuthorityPayload(parsed);
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 existing = buffers.find((b) => b.path === path);
1558
- if (existing && existing.splits.length > 0) {
1559
- editor.focusSplit(existing.splits[0]);
1560
- return;
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
- const result = await editor.createVirtualBufferInSplit({
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: "dismiss", label: editor.t("popup.attach_action_dismiss") },
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", "devcontainer_on_lifecycle_confirmed");
1890
- editor.on("action_popup_result", "devcontainer_on_action_result");
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.cancel_attach",
1959
- "%cmd.cancel_attach_desc",
1960
- "devcontainer_cancel_attach",
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
- // One-shot per-session dismissal: if the user already said "Not
2053
- // now" in this Editor process, don't re-prompt. On a cold restart
2054
- // the state is gone and we ask again that's fine.
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
- registerHandler(
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
- editor.debug("Dev Container plugin: no devcontainer.json found");
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
  }