Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution';
import { DevContainerFile, LastContainerInfo, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider';
import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service';
Expand All @@ -32,9 +32,29 @@ export namespace RemoteContainerCommands {
label: 'Reopen in Container',
category: 'Dev Container'
}, 'theia/remote/dev-container/connect');

export const ATTACH_TO_CONTAINER = Command.toLocalizedCommand({
id: 'dev-container:attach-to-container',
label: 'Attach to Running Container',
category: 'Dev Container'
}, 'theia/remote/dev-container/attach');

export const REBUILD_CONTAINER = Command.toLocalizedCommand({
id: 'dev-container:rebuild-container',
label: 'Rebuild Container',
category: 'Dev Container'
}, 'theia/remote/dev-container/rebuild');
}

const LAST_USED_CONTAINER = 'lastUsedContainer';
const ACTIVE_DEV_CONTAINER_CONTEXT = 'activeDevContainerContext';

interface DevContainerContext {
devcontainerFilePath: string;
devcontainerFileName: string;
hostWorkspacePath: string;
containerId: string;
}
@injectable()
export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution implements WorkspaceOpenHandlerContribution {

Expand Down Expand Up @@ -65,28 +85,85 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr
@inject(ContainerOutputProvider)
protected readonly containerOutputProvider: ContainerOutputProvider;

protected hasDevContainerFiles = false;

@postConstruct()
protected init(): void {
// Mark that we're in a remote session. sessionStorage survives page
// reloads (disconnect) but is cleared on window close (restart).
// This lets canHandle() distinguish disconnect from restart.
if (this.isRemoteSession()) {
sessionStorage.setItem('devcontainer:wasRemote', 'true');
}
this.workspaceService.ready.then(() => this.checkForDevContainerFiles());
this.workspaceService.onWorkspaceChanged(() => this.checkForDevContainerFiles());
}

protected async checkForDevContainerFiles(): Promise<void> {
if (this.isRemoteSession()) {
this.hasDevContainerFiles = true;
return;
}
const workspace = this.workspaceService.workspace;
if (!workspace) {
this.hasDevContainerFiles = false;
return;
}
try {
const files = await this.connectionProvider.getDevContainerFiles(workspace.resource.path.toString());
this.hasDevContainerFiles = files.length > 0;
} catch (error) {
// Failed to check for devcontainer files, assume none exist
this.hasDevContainerFiles = false;
}
}

registerRemoteCommands(registry: RemoteRegistry): void {
registry.registerCommand(RemoteContainerCommands.REOPEN_IN_CONTAINER, {
execute: () => this.openInContainer()
execute: () => this.openInContainer(),
isVisible: () => !this.isRemoteSession() && this.hasDevContainerFiles
});
registry.registerCommand(RemoteContainerCommands.ATTACH_TO_CONTAINER, {
execute: () => this.attachToContainer()
});
registry.registerCommand(RemoteContainerCommands.REBUILD_CONTAINER, {
execute: () => this.rebuildContainer(),
isVisible: () => this.isRemoteSession()
});
}

protected isRemoteSession(): boolean {
return new URLSearchParams(window.location.search).has('localPort');
}

canHandle(uri: URI): MaybePromise<boolean> {
return uri.scheme === DEV_CONTAINER_WORKSPACE_SCHEME;
if (uri.scheme !== DEV_CONTAINER_WORKSPACE_SCHEME) {
return false;
}
// After disconnect (reload), sessionStorage still has the flag from
// the remote session's init. Skip auto-reopen so the user gets their
// local workspace. After restart (close+open), sessionStorage is
// cleared, so auto-reopen works.
const wasRemote = sessionStorage.getItem('devcontainer:wasRemote');
if (wasRemote) {
sessionStorage.removeItem('devcontainer:wasRemote');
return false;
}
return true;
}

async openWorkspace(uri: URI, options?: WorkspaceInput | undefined): Promise<void> {
const filePath = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY);

if (!filePath) {
throw new Error('No devcontainer file specified for workspace');
throw new Error(nls.localize('theia/dev-container/noDevcontainerFileSpecified', 'No devcontainer file specified for workspace'));
}

const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(uri.path.toString());
const devcontainerFile = devcontainerFiles.find(file => file.path === filePath);

if (!devcontainerFile) {
throw new Error(`Devcontainer file at ${filePath} not found in workspace`);
throw new Error(nls.localize('theia/dev-container/devcontainerFileNotFound', 'Devcontainer file at {0} not found in workspace', filePath));
}

return this.doOpenInContainer(devcontainerFile, uri.path.toString());
Expand All @@ -110,26 +187,115 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr
this.doOpenInContainer(devcontainerFile);
}

async attachToContainer(): Promise<void> {
const containers = await this.connectionProvider.listRunningContainers();
if (containers.length === 0) {
this.messageService.info(nls.localize('theia/remote/dev-container/noRunningContainers', 'No running containers found.'));
return;
}

const selected = await this.quickInputService.pick(containers.map(container => ({
type: 'item' as const,
label: container.name || container.id.substring(0, 12),
description: container.image,
detail: container.status,
container
})), {
title: nls.localize('theia/remote/dev-container/selectContainer', 'Select a running container to attach to')
});

if (!selected) {
return;
}

this.containerOutputProvider.openChannel();

const connectionResult = await this.connectionProvider.attachToContainer(selected.container.id);
this.openRemote(connectionResult.port, false, connectionResult.workspacePath);
}

async rebuildContainer(): Promise<void> {
this.containerOutputProvider.openChannel();
const progress = await this.messageService.showProgress({
text: nls.localize('theia/remote/dev-container/rebuilding', 'Rebuilding dev container...')
});

try {
// When inside a remote container, read the stored context instead of
// scanning the filesystem (the RPC goes to the local backend which
// doesn't have the container's workspace path).
const ctx = await this.storageService.getData<DevContainerContext | undefined>(ACTIVE_DEV_CONTAINER_CONTEXT);
if (ctx) {
progress.report({ message: nls.localize('theia/dev-container/removingOldContainer', 'Removing old container...') });
try {
await this.connectionProvider.removeContainer(ctx.containerId);
} catch (error) {
// Container may already be gone, ignore error
}
const lastContainerKey = `${LAST_USED_CONTAINER}:${ctx.devcontainerFilePath}`;
await this.storageService.setData(lastContainerKey, undefined);
progress.cancel();
this.doOpenInContainer(
{ path: ctx.devcontainerFilePath, name: ctx.devcontainerFileName },
ctx.hostWorkspacePath
);
return;
}

// Fallback: local workspace — scan for devcontainer files
const devcontainerFile = await this.getOrSelectDevcontainerFile();
if (!devcontainerFile) {
return;
}
const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile.path}`;
const lastContainerInfo = await this.storageService.getData<LastContainerInfo | undefined>(lastContainerInfoKey);
if (lastContainerInfo) {
progress.report({ message: nls.localize('theia/dev-container/removingOldContainer', 'Removing old container...') });
try {
await this.connectionProvider.removeContainer(lastContainerInfo.id);
} catch (error) {
// Container may already be gone, ignore error
}
await this.storageService.setData(lastContainerInfoKey, undefined);
}
progress.cancel();
this.doOpenInContainer(devcontainerFile);
} catch (e) {
progress.cancel();
this.messageService.error(nls.localize('theia/dev-container/failedToRebuild', 'Failed to rebuild container: {0}', (e as Error).message));
}
}

async doOpenInContainer(devcontainerFile: DevContainerFile, workspacePath?: string): Promise<void> {
const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile.path}`;
const lastContainerInfo = await this.storageService.getData<LastContainerInfo | undefined>(lastContainerInfoKey);

this.containerOutputProvider.openChannel();

const hostWorkspacePath = workspacePath ?? this.workspaceService.workspace?.resource.path.toString();

const connectionResult = await this.connectionProvider.connectToContainer({
nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'],
lastContainerInfo,
devcontainerFile: devcontainerFile.path,
workspacePath: workspacePath
workspacePath: hostWorkspacePath
});

this.storageService.setData<LastContainerInfo>(lastContainerInfoKey, {
id: connectionResult.containerId,
lastUsed: Date.now()
});

// Store full context so rebuild works from inside the container
this.storageService.setData<DevContainerContext>(ACTIVE_DEV_CONTAINER_CONTEXT, {
devcontainerFilePath: devcontainerFile.path,
devcontainerFileName: devcontainerFile.name,
hostWorkspacePath: hostWorkspacePath ?? '',
containerId: connectionResult.containerId,
});

this.workspaceServer.setMostRecentlyUsedWorkspace(
`${DEV_CONTAINER_WORKSPACE_SCHEME}:${workspacePath ?? this.workspaceService.workspace?.resource.path}?${DEV_CONTAINER_PATH_QUERY}=${devcontainerFile.path}`);
`${DEV_CONTAINER_WORKSPACE_SCHEME}:${hostWorkspacePath}?${DEV_CONTAINER_PATH_QUERY}=${devcontainerFile.path}`);

this.openRemote(connectionResult.port, false, connectionResult.workspacePath);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ContainerInfoContribution } from './container-info-contribution';
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
import { WorkspaceOpenHandlerContribution } from '@theia/workspace/lib/browser/workspace-service';
import { WindowTitleContribution } from '@theia/core/lib/browser/window/window-title-service';
import { DevContainerSuggestionContribution } from './dev-container-suggestion-contribution';

export default new ContainerModule(bind => {
bind(ContainerConnectionContribution).toSelf().inSingletonScope();
Expand All @@ -40,4 +41,7 @@ export default new ContainerModule(bind => {
bind(FrontendApplicationContribution).toService(ContainerInfoContribution);
bind(WindowTitleContribution).toService(ContainerInfoContribution);
bind(LabelProviderContribution).toService(ContainerInfoContribution);

bind(DevContainerSuggestionContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(DevContainerSuggestionContribution);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// *****************************************************************************
// Copyright (C) 2026 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { CommandService, MessageService, nls } from '@theia/core';
import { FrontendApplicationContribution, LocalStorageService } from '@theia/core/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider';
import { RemoteContainerCommands } from './container-connection-contribution';
import { RemoteStatusService } from '@theia/remote/lib/electron-common/remote-status-service';

const DONT_SHOW_AGAIN_KEY = 'dev-container.suggestion.dontShowAgain';

@injectable()
export class DevContainerSuggestionContribution implements FrontendApplicationContribution {

@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;

@inject(RemoteContainerConnectionProvider)
protected readonly connectionProvider: RemoteContainerConnectionProvider;

@inject(MessageService)
protected readonly messageService: MessageService;

@inject(CommandService)
protected readonly commandService: CommandService;

@inject(RemoteStatusService)
protected readonly remoteStatusService: RemoteStatusService;

@inject(LocalStorageService)
protected readonly storageService: LocalStorageService;

onStart(): void {
this.checkForDevContainer();
}

protected async checkForDevContainer(): Promise<void> {
const containerPort = parseInt(new URLSearchParams(location.search).get('port') ?? '0');
if (containerPort > 0) {
const status = await this.remoteStatusService.getStatus(containerPort);
if (status?.alive) {
return;
}
}

const dontShowAgain = await this.storageService.getData<boolean>(DONT_SHOW_AGAIN_KEY);
if (dontShowAgain) {
return;
}

await this.workspaceService.ready;
const workspace = this.workspaceService.workspace;
if (!workspace) {
return;
}

try {
const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(workspace.resource.path.toString());
if (devcontainerFiles.length > 0) {
const reopenAction = nls.localize('theia/remote/dev-container/reopenInContainer', 'Reopen in Container');
const dontShowAgainAction = nls.localizeByDefault("Don't Show Again");
const result = await this.messageService.info(
nls.localize('theia/remote/dev-container/suggestion',
'This workspace has a dev container configuration. Would you like to reopen it in a container?'),
Comment thread
sgraband marked this conversation as resolved.
reopenAction,
dontShowAgainAction
);
if (result === reopenAction) {
this.commandService.executeCommand(RemoteContainerCommands.REOPEN_IN_CONTAINER.id);
} else if (result === dontShowAgainAction) {
await this.storageService.setData(DONT_SHOW_AGAIN_KEY, true);
}
}
} catch (error) {
// Silently ignore if we can't check for devcontainer files
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,18 @@ export interface DevContainerFile {
path: string;
}

export interface RunningContainerInfo {
id: string;
name: string;
image: string;
status: string;
}

export interface RemoteContainerConnectionProvider extends RpcServer<ContainerOutputProvider> {
connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult>;
getDevContainerFiles(workspacePath: string): Promise<DevContainerFile[]>;
getCurrentContainerInfo(port: number): Promise<ContainerInspectInfo | undefined>;
listRunningContainers(): Promise<RunningContainerInfo[]>;
attachToContainer(containerId: string): Promise<ContainerConnectionResult>;
removeContainer(containerId: string): Promise<void>;
}
Loading
Loading