дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(remote): reopen a local project in its dev container (#1843)
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI. Co-authored-by: andreabasile <[email protected]>
Цей коміт міститься в:
1 джерело
7479b7ad9d
коміт
60d7dcdca0
29 files changed
+908
-69
No files matched your search
@@ -4,9 +4,15 @@ import type { RemoteSession } from "@/platform/ipc";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { BarButton } from "@/ui";
|
||||
|
||||
const BADGE: Record<RemoteSession["target"]["kind"], string> = {
|
||||
ssh: "SSH",
|
||||
wsl: "WSL",
|
||||
devContainer: "Dev Container",
|
||||
};
|
||||
|
||||
/** Status bar label of a remote window: where its project runs. */
|
||||
export function remoteBadge(session: Pick<RemoteSession, "target" | "host">): string {
|
||||
return `${session.target.kind === "wsl" ? "WSL" : "SSH"}: ${session.host}`;
|
||||
return `${BADGE[session.target.kind]}: ${session.host}`;
|
||||
}
|
||||
|
||||
export function RemoteStatus() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn(), notify: vi.fn() }));
|
||||
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: mocks.open }));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: mocks.notify }) }));
|
||||
|
||||
import { reopenInContainer } from "./container";
|
||||
|
||||
const project = (root: string) => ({ root, name: "app", trusted: true }) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useRemote.setState({ session: null });
|
||||
useProject.setState({ project: project("/Users/me/app") });
|
||||
});
|
||||
|
||||
it("opens the local project's dev container", async () => {
|
||||
mocks.open.mockResolvedValue(true);
|
||||
await reopenInContainer();
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
{ kind: "devContainer", folder: "/Users/me/app", config: "" },
|
||||
"/Users/me/app",
|
||||
);
|
||||
expect(mocks.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing in a remote window or without a project", async () => {
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "a" }, root: "/srv/app", host: "a", os: "linux", commands: [] },
|
||||
});
|
||||
await reopenInContainer();
|
||||
useRemote.setState({ session: null });
|
||||
useProject.setState({ project: null });
|
||||
await reopenInContainer();
|
||||
expect(mocks.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports why the container could not be opened, but not a cancel", async () => {
|
||||
mocks.open.mockRejectedValueOnce(new Error("The Dev Containers CLI is not installed."));
|
||||
await reopenInContainer();
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", body: "The Dev Containers CLI is not installed." }),
|
||||
);
|
||||
mocks.notify.mockClear();
|
||||
mocks.open.mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Project opening cancelled" }));
|
||||
await reopenInContainer();
|
||||
expect(mocks.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Reopen in Container: the local project's dev container, opened as a remote project. */
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openRemoteProject } from "@/workbench/projectWindow";
|
||||
|
||||
/**
|
||||
* Builds or starts the dev container of this window's local project (its `devcontainer.json`)
|
||||
* and opens the project there. The folder must be trusted: building it runs the project's commands.
|
||||
*/
|
||||
export async function reopenInContainer(): Promise<void> {
|
||||
const project = useProject.getState().project;
|
||||
if (!project || isRemoteWindow()) return;
|
||||
try {
|
||||
await openRemoteProject({ kind: "devContainer", folder: project.root, config: "" }, project.root);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return;
|
||||
notifications().notify({
|
||||
group: "remote",
|
||||
level: "error",
|
||||
title: tx("Could not reopen the project in a container"),
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Remote development: a window whose project lives on an SSH host or in a WSL distribution. The backend
|
||||
* (`openidea-server`) runs there and this window routes its commands to it
|
||||
* (platform/remote.ts).
|
||||
* Remote development: a window whose project lives on an SSH host, in a WSL distribution or in a
|
||||
* dev container. The backend (`openidea-server`) runs there and this window routes its commands
|
||||
* to it (platform/remote.ts).
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { disconnectRemote, useRemote } from "@/platform/remote";
|
||||
@@ -9,6 +9,7 @@ import { lazyComponent } from "@/ui";
|
||||
import { maybeEditorService, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import { confirmLeaveProject } from "@/platform/project";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { reopenInContainer } from "./container";
|
||||
import { reloadEnvironment } from "./environment";
|
||||
import { RemoteStatus } from "./RemoteStatus";
|
||||
import { closeConnectDialog, openConnectDialog } from "./model";
|
||||
@@ -80,6 +81,17 @@ export function activate(): () => void {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote.reopenInContainer",
|
||||
title: "Reopen in Container",
|
||||
description:
|
||||
"Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.",
|
||||
category: "File",
|
||||
icon: "folder",
|
||||
keywords: ["devcontainer", "dev container", "docker", "container", "remote"],
|
||||
when: "projectOpen",
|
||||
run: () => reopenInContainer(),
|
||||
},
|
||||
{
|
||||
id: "remote.reloadEnvironment",
|
||||
title: "Reload Environment",
|
||||
|
||||
@@ -39,6 +39,8 @@ export function stageLabel(stage: RemoteStage): string {
|
||||
switch (stage) {
|
||||
case "connecting":
|
||||
return "Connecting to the host…";
|
||||
case "container":
|
||||
return "Starting the dev container…";
|
||||
case "checking":
|
||||
return "Checking the IDE backend on the host…";
|
||||
case "downloading":
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import { PortsView } from "./PortsView";
|
||||
import { parsePort, usePorts } from "./ports";
|
||||
import { forwardPort, parsePort, usePorts } from "./ports";
|
||||
|
||||
ipc.remoteForwardedPorts.mockImplementation(async () => [...forwarded]);
|
||||
ipc.remoteForwardPort.mockImplementation(async (remotePort: number) => {
|
||||
@@ -86,3 +86,20 @@ describe("restoring forwarded ports", () => {
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't forward ports of a dev container yet", async () => {
|
||||
const { useRemote } = await import("@/platform/remote");
|
||||
ipc.remoteForwardPort.mockClear();
|
||||
useRemote.setState({
|
||||
session: {
|
||||
target: { kind: "devContainer", folder: "/w/app", config: "" },
|
||||
root: "/workspaces/app",
|
||||
host: "app",
|
||||
os: "linux",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(await forwardPort(8080)).toBeNull();
|
||||
expect(ipc.remoteForwardPort).not.toHaveBeenCalled();
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
@@ -53,11 +53,15 @@ export function parsePort(text: string): number | null {
|
||||
}
|
||||
|
||||
export async function forwardPort(port: number): Promise<ForwardedPort | null> {
|
||||
if (useRemote.getState().session?.target.kind === "wsl") {
|
||||
const kind = useRemote.getState().session?.target.kind;
|
||||
if (kind === "wsl" || kind === "devContainer") {
|
||||
notifications().notify({
|
||||
group: "remote",
|
||||
level: "error",
|
||||
title: tx("Forwarding ports is not supported in a WSL project yet"),
|
||||
title:
|
||||
kind === "wsl"
|
||||
? tx("Forwarding ports is not supported in a WSL project yet")
|
||||
: tx("Forwarding ports is not supported in a dev container yet"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("remote folder browser", () => {
|
||||
|
||||
const ssh = (config: string) => ({ kind: "ssh", config }) as const;
|
||||
const wsl = (distro: string) => ({ kind: "wsl", distro }) as const;
|
||||
const container = (folder: string) => ({ kind: "devContainer", folder, config: "" }) as const;
|
||||
|
||||
describe("remote project locations", () => {
|
||||
it("round-trip the configuration id and the path", () => {
|
||||
@@ -66,6 +67,20 @@ describe("remote project locations", () => {
|
||||
expect(parseRemoteLocation("wsl://Ubuntu")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip a dev container's local folder and the path in the container", () => {
|
||||
const loc = remoteLocation(container("/Users/me/my app"), "/workspaces/my app");
|
||||
expect(loc).toBe("devcontainer://%2FUsers%2Fme%2Fmy%20app/workspaces/my app");
|
||||
expect(parseRemoteLocation(loc)).toEqual({ target: container("/Users/me/my app"), path: "/workspaces/my app" });
|
||||
});
|
||||
|
||||
it("round-trips container configurations and Windows folders with reserved characters", () => {
|
||||
const target = { ...container("C:\\work\\my app"), config: ".devcontainer/a:b?c/devcontainer.json" };
|
||||
expect(parseRemoteLocation(remoteLocation(target, "/workspaces/app"))).toEqual({
|
||||
target,
|
||||
path: "/workspaces/app",
|
||||
});
|
||||
});
|
||||
|
||||
it("remembers recent remote projects most recent first, once each", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
|
||||
@@ -85,6 +100,19 @@ describe("remote project locations", () => {
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("dev")]);
|
||||
});
|
||||
|
||||
it("keeps the dev containers of two configurations of a folder apart", () => {
|
||||
localStorage.clear();
|
||||
const python = { ...container("/w/app"), config: ".devcontainer/python/devcontainer.json" };
|
||||
rememberRemote({ target: container("/w/app"), path: "/workspaces/app", name: "app", host: "app", openedAtMs: 1 });
|
||||
rememberRemote({ target: python, path: "/workspaces/app", name: "app", host: "app", openedAtMs: 2 });
|
||||
expect(recentRemotes()).toHaveLength(2);
|
||||
const saved = remoteLocation(python, "/workspaces/app");
|
||||
expect(saved).not.toBe(remoteLocation(container("/w/app"), "/workspaces/app"));
|
||||
expect(parseRemoteLocation(saved)).toEqual({ target: python, path: "/workspaces/app" });
|
||||
forgetRemote(parseRemoteLocation(saved)!.target, "/workspaces/app");
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([container("/w/app")]);
|
||||
});
|
||||
|
||||
it("reads recents saved before WSL support, which carried `sshConfig`", () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
@@ -123,4 +151,5 @@ it("migrates a snapshot taken before WSL support, and never trusts a WSL distrib
|
||||
it("labels the status bar with the transport and where the project runs", () => {
|
||||
expect(remoteBadge({ target: wsl("Ubuntu"), host: "Ubuntu" })).toBe("WSL: Ubuntu");
|
||||
expect(remoteBadge({ target: ssh("a"), host: "me@a:22" })).toBe("SSH: me@a:22");
|
||||
expect(remoteBadge({ target: container("/w/app"), host: "app" })).toBe("Dev Container: app");
|
||||
});
|
||||
@@ -40,6 +40,7 @@ const KIND_LABEL: Record<ToolInfo["kind"], string> = {
|
||||
debugger: "Debugger",
|
||||
plugin: "Plugin",
|
||||
formatter: "Formatter",
|
||||
cli: "Command-line tool",
|
||||
};
|
||||
|
||||
export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] {
|
||||
|
||||
Reference in new issue
Заблокувати користувача