// ***************************************************************************** // Copyright (C) 2024 Typefox 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, postConstruct } from '@theia/core/shared/inversify'; import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; import { DevContainerFile, LastContainerInfo, RemoteContainerConnectionProvider, RunningContainerInfo, WorkspaceCandidate } from '../electron-common/remote-container-connection-provider'; import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service'; import { Command, ILogger, MaybePromise, MessageService, nls, QuickInputService, URI } from '@theia/core'; import { WorkspaceInput, WorkspaceOpenHandlerContribution, WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { ContainerOutputProvider } from './container-output-provider'; import { WorkspaceServer } from '@theia/workspace/lib/common'; import { DEV_CONTAINER_PATH_QUERY, DEV_CONTAINER_WORKSPACE_SCHEME } from '../electron-common/dev-container-workspaces'; import { RemotePreferences } from '@theia/remote/lib/electron-common/remote-preferences'; import { LocalStorageService } from '@theia/core/lib/browser'; import { ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs'; import { DevContainerPreferences } from '../electron-common/dev-container-preferences'; export namespace RemoteContainerCommands { export const REOPEN_IN_CONTAINER = Command.toLocalizedCommand({ id: 'dev-container:reopen-in-container', 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 { @inject(RemoteContainerConnectionProvider) protected readonly connectionProvider: RemoteContainerConnectionProvider; @inject(RemotePreferences) protected readonly remotePreferences: RemotePreferences; @inject(MessageService) protected readonly messageService: MessageService; @inject(WorkspaceStorageService) protected readonly workspaceStorageService: WorkspaceStorageService; @inject(LocalStorageService) protected readonly storageService: LocalStorageService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(WorkspaceServer) protected readonly workspaceServer: WorkspaceServer; @inject(QuickInputService) protected readonly quickInputService: QuickInputService; @inject(ContainerOutputProvider) protected readonly containerOutputProvider: ContainerOutputProvider; @inject(DevContainerPreferences) protected readonly devContainerPreferences: DevContainerPreferences; @inject(ILogger) protected readonly logger: ILogger; 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 { 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(), isVisible: () => !this.isRemoteSession() && this.hasDevContainerFiles }); registry.registerCommand(RemoteContainerCommands.REBUILD_CONTAINER, { execute: () => this.rebuildContainer(), isVisible: () => this.isRemoteSession() }); registry.registerCommand(RemoteContainerCommands.ATTACH_TO_CONTAINER, { execute: () => this.attachToRunningContainer() }); } protected isRemoteSession(): boolean { return new URLSearchParams(window.location.search).has('localPort'); } canHandle(uri: URI): MaybePromise { 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 { const filePath = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY); if (!filePath) { 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(nls.localize('theia/dev-container/devcontainerFileNotFound', 'Devcontainer file at {0} not found in workspace', filePath)); } return this.doOpenInContainer(devcontainerFile, uri.path.toString()); } async getWorkspaceLabel(uri: URI): Promise { const containerFilePath = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY); if (!containerFilePath) { return; }; const files = await this.connectionProvider.getDevContainerFiles(uri.path.toString()); const devcontainerFile = files.find(file => file.path === containerFilePath); return `${uri.path.base} [Dev Container: ${devcontainerFile?.name}]`; } async openInContainer(): Promise { const devcontainerFile = await this.getOrSelectDevcontainerFile(); if (!devcontainerFile) { return; } this.doOpenInContainer(devcontainerFile); } async rebuildContainer(): Promise { 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(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) { this.logger.debug('Container removal failed (may already be gone):', 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(lastContainerInfoKey); if (lastContainerInfo) { progress.report({ message: nls.localize('theia/dev-container/removingOldContainer', 'Removing old container...') }); try { await this.connectionProvider.removeContainer(lastContainerInfo.id); } catch (error) { this.logger.debug('Container removal failed (may already be gone):', 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 { const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile.path}`; const lastContainerInfo = await this.storageService.getData(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: hostWorkspacePath }); await this.storageService.setData(lastContainerInfoKey, { id: connectionResult.containerId, lastUsed: Date.now() }); // Store full context so rebuild works from inside the container await this.storageService.setData(ACTIVE_DEV_CONTAINER_CONTEXT, { devcontainerFilePath: devcontainerFile.path, devcontainerFileName: devcontainerFile.name, hostWorkspacePath: hostWorkspacePath ?? '', containerId: connectionResult.containerId, }); await this.workspaceServer.setMostRecentlyUsedWorkspace( `${DEV_CONTAINER_WORKSPACE_SCHEME}:${hostWorkspacePath}?${DEV_CONTAINER_PATH_QUERY}=${devcontainerFile.path}`); this.openRemote(connectionResult.port, false, connectionResult.workspacePath); } async attachToRunningContainer(): Promise { try { const confirmed = await new ConfirmDialog({ title: nls.localize('theia/remote/dev-container/attachWarningTitle', 'Attach to Container'), msg: nls.localize('theia/remote/dev-container/attachWarning', 'Attaching to a container will execute code inside it. Only attach to containers whose origin you trust.'), ok: nls.localizeByDefault('Continue'), cancel: Dialog.CANCEL, }).open(); if (!confirmed) { return; } 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 selectedItem = await this.quickInputService.pick(containers.map(container => ({ type: 'item' as const, label: container.name, description: container.image, detail: container.status, container })), { title: nls.localize('theia/remote/dev-container/selectContainer', 'Select a running container to attach to') }); if (!selectedItem) { return; } const selectedContainer: RunningContainerInfo = selectedItem.container; const candidates: WorkspaceCandidate[] = await this.connectionProvider.getWorkspaceCandidates(selectedContainer.id); const customPathId = '__custom_path__'; const pathItem = await this.quickInputService.pick([ ...candidates.map(candidate => ({ type: 'item' as const, label: candidate.path, description: candidate.source })), { type: 'item' as const, id: customPathId, label: nls.localize('theia/remote/dev-container/enterCustomPath', 'Enter custom path...'), description: '' } ], { title: nls.localize('theia/remote/dev-container/selectWorkspacePath', 'Select a workspace path inside the container') }); if (!pathItem) { return; } let selectedPath: string | undefined; if ('id' in pathItem && pathItem.id === customPathId) { selectedPath = await this.quickInputService.input({ prompt: nls.localize('theia/remote/dev-container/enterWorkspacePath', 'Enter the workspace path inside the container'), value: '/' }); } else { selectedPath = pathItem.label; } if (!selectedPath) { return; } let devcontainerFile: string | undefined; const applyConfigPref = this.devContainerPreferences['devcontainer.attach.applyFoundConfig']; if (applyConfigPref !== 'never') { const foundConfig = await this.connectionProvider.scanForDevContainerConfig(selectedContainer.id, selectedPath); if (foundConfig) { if (applyConfigPref === 'always') { devcontainerFile = foundConfig; } else { // 'ask' const applyConfig = await new ConfirmDialog({ title: nls.localize('theia/remote/dev-container/foundConfigTitle', 'Dev Container Configuration Found'), msg: nls.localize('theia/remote/dev-container/foundConfigMsg', 'A devcontainer.json was found at {0}. Would you like to apply its post-attach settings (extensions, port forwarding, etc.)?', foundConfig), ok: nls.localizeByDefault('Apply'), cancel: Dialog.CANCEL, }).open(); if (applyConfig) { devcontainerFile = foundConfig; } } } } this.containerOutputProvider.openChannel(); const progress = await this.messageService.showProgress({ text: nls.localize('theia/remote/dev-container/attaching', 'Attaching to container') }); try { const connectionResult = await this.connectionProvider.attachToContainer({ containerId: selectedContainer.id, workspacePath: selectedPath, nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'], devcontainerFile, }); // Store context so rebuild works from inside the container await this.storageService.setData(ACTIVE_DEV_CONTAINER_CONTEXT, { devcontainerFilePath: devcontainerFile ?? '', devcontainerFileName: devcontainerFile ? URI.fromFilePath(devcontainerFile).path.base : '', hostWorkspacePath: '', containerId: connectionResult.containerId, }); this.openRemote(connectionResult.port, false, connectionResult.workspacePath); } finally { progress.cancel(); } } catch (e) { this.messageService.error(nls.localize( 'theia/remote/dev-container/attachError', 'Failed to attach to container: {0}', e instanceof Error ? e.message : String(e) )); } } async getOrSelectDevcontainerFile(): Promise { const workspace = this.workspaceService.workspace; if (!workspace) { return; } const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(workspace.resource.path.toString()); if (devcontainerFiles.length === 1) { return devcontainerFiles[0]; } else if (devcontainerFiles.length === 0) { // eslint-disable-next-line max-len this.messageService.error(nls.localize('theia/remote/dev-container/noDevcontainerFiles', 'No devcontainer.json files found in the workspace. Please ensure you have a .devcontainer directory with a devcontainer.json file.')); return undefined; } return (await this.quickInputService.pick(devcontainerFiles.map(file => ({ type: 'item', label: file.name, description: file.path, file: file, })), { title: nls.localize('theia/remote/dev-container/selectDevcontainer', 'Select a devcontainer.json file') }))?.file; } }