feat(remote): Reload the environment and apply devcontainer settings on the remote server (#183)

The remote server read the login shell's PATH once and cached it, had no project-level environment, and changed environment variables only took effect after reinstalling or a manual restart.

- Every openidea-server start re-reads the remote login shell's full environment (`$SHELL -ilc env -0`), with the cached PATH only as a fallback when the shell doesn't answer.
- A project's `.devcontainer/devcontainer.json` (or `.devcontainer.json`) applies on remote hosts: `remoteEnv` (with `${localEnv|containerEnv|env:X[:default]}`, workspace variables and `null` to unset) reaches every child process through one environment overlay (terminals, run configurations, language servers, debuggers, build tools, git), and `postStartCommand` (string, array or parallel object) runs once per server start in the project root as a cancellable background task; a failure raises a notification with the exit code and the last output lines without blocking the project.
- Reload Environment (`remote.reloadEnvironment`, File menu and Find Action) re-resolves the login shell and `remoteEnv` without restarting the server, starting from the process's original environment so prepended PATH entries don't pile up; it lists the changed variables, notes that running processes keep their environment, and offers to restart language servers.
- The JSONC reader moves to `ide_core::jsonc` and is shared with ide-run.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 09:28:18 +08:00
1 джерело b6a371559a
коміт 1f9be73174
28 files changed
+1527 -123

No files matched your search

+66
Переглянути файл
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EnvReloaded, IdeError } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
type Outcome = { status: "ok"; data: EnvReloaded } | { status: "error"; error: IdeError };
let outcome: Outcome;
const envReload = vi.fn(async () => outcome);
const lspServers = vi.fn(async () => ({ status: "ok" as const, data: [{ id: 1 }, { id: 2 }] }));
const lspRestart = vi.fn(async (_id: number) => ({ status: "ok" as const, data: null }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, commands: { ...actual.commands, envReload, lspServers, lspRestart } };
});
const { reloadEnvironment, reloadSummary } = await import("./environment");
const { provideNotificationService } = await import("@/workbench/contributions");
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
beforeEach(() => {
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
vi.clearAllMocks();
});
const reloaded = (changed: string[]): EnvReloaded => ({ changed, devcontainer: null, shellRead: true });
describe("Reload Environment", () => {
it("says what changed and that running processes keep their environment", () => {
expect(reloadSummary(reloaded([]))).toBe(
"No variables changed. Terminals and processes already running keep their old environment; new ones get the new one.",
);
expect(reloadSummary(reloaded(["JAVA_HOME", "PATH"]))).toMatch(/^Changed: JAVA_HOME, PATH\. Terminals/);
const many = Array.from({ length: 10 }, (_, i) => `V${i}`);
expect(reloadSummary(reloaded(many))).toMatch(/^Changed: V0, V1, V2, V3, V4, V5, V6, V7 and 2 more\./);
});
it("offers to restart the language servers when something changed", async () => {
outcome = { status: "ok", data: reloaded(["PATH"]) };
await reloadEnvironment();
expect(envReload).toHaveBeenCalledOnce();
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ level: "info", title: "Environment reloaded" });
const restart = notes[0]!.actions!.find((a) => a.label === "Restart Language Servers")!;
restart.run();
await vi.waitFor(() => expect(lspRestart).toHaveBeenCalledTimes(2));
expect(lspRestart.mock.calls.map((c) => c[0])).toEqual([1, 2]);
});
it("has nothing to restart when nothing changed", async () => {
outcome = { status: "ok", data: reloaded([]) };
await reloadEnvironment();
expect(notes[0]!.actions).toEqual([]);
});
it("reports a failure as an error", async () => {
outcome = {
status: "error",
error: { kind: "unavailable", message: "could not read the environment of the login shell (/bin/zsh)" },
} as Outcome;
await reloadEnvironment();
expect(notes[0]).toMatchObject({ level: "error", title: "Could not reload the environment" });
expect(String(notes[0]!.body)).toContain("login shell");
});
});
+64
Переглянути файл
@@ -0,0 +1,64 @@
/**
* Reload Environment: new terminals, runs, language servers and tools get the login
* shell's current environment (and, on a remote host, the project's `devcontainer.json`
* `remoteEnv`) without restarting the backend. What is already running keeps its own.
*/
import { tx } from "@/platform/i18n";
import { call, commands, type EnvReloaded } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
const KEY = "remote.reloadEnvironment";
/** Variable names listed in the notification before "and N more". */
const LISTED = 8;
/** The notification text for a reload result. */
export function reloadSummary(r: EnvReloaded): string {
const names = r.changed.slice(0, LISTED).join(", ");
const more = r.changed.length - LISTED;
const changed =
r.changed.length === 0
? tx("No variables changed.")
: more > 0
? tx`Changed: ${names} and ${more} more.`
: tx`Changed: ${names}.`;
const keep = tx("Terminals and processes already running keep their old environment; new ones get the new one.");
return [changed, keep].join(" ");
}
async function restartLanguageServers() {
try {
const servers = await call(commands.lspServers());
await Promise.all(servers.map((s) => call(commands.lspRestart(s.id))));
} catch (e) {
notifications().notify({
group: "lsp",
level: "error",
title: "Failed to restart language server",
body: e instanceof Error ? e.message : String(e),
});
}
}
export async function reloadEnvironment() {
let result: EnvReloaded;
try {
result = await call(commands.envReload());
} catch (e) {
notifications().notify({
key: KEY,
level: "error",
title: "Could not reload the environment",
body: e instanceof Error ? e.message : String(e),
});
return;
}
notifications().notify({
key: KEY,
level: "info",
title: "Environment reloaded",
body: reloadSummary(result),
actions: result.changed.length
? [{ label: "Restart Language Servers", run: () => void restartLanguageServers() }]
: [],
});
}
+11
Переглянути файл
@@ -7,6 +7,7 @@ import { registerActions } from "@/platform/actions";
import { disconnectRemote } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { ConnectHost } from "./ConnectDialog";
import { reloadEnvironment } from "./environment";
import { closeConnectDialog, openConnectDialog } from "./model";
export function activate(): () => void {
@@ -30,6 +31,16 @@ export function activate(): () => void {
when: "projectOpen",
run: () => disconnectRemote(),
},
{
id: "remote.reloadEnvironment",
title: "Reload Environment",
description:
"Reads the login shell's environment (and devcontainer.json remoteEnv on a remote host) again for new terminals and processes.",
category: "File",
keywords: ["env", "environment", "variables", "path", "shell", "remoteEnv", "devcontainer", "reload"],
when: "projectOpen",
run: () => reloadEnvironment(),
},
]),
];
return () => {