Skip to content
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

JetBrains Toolbox plugin that enables local IntelliJ IDEs to connect to Red Hat OpenShift Dev Spaces (Eclipse Che) workspaces via SSH.

## Build & Install Commands

```bash
# Build the plugin JAR
./gradlew build

# Build and install to JetBrains Toolbox
./gradlew installPlugin

# Clean build artifacts
./gradlew clean
```

After installing, restart the Toolbox App to load the plugin.

## Architecture

```
DataSource → EnvironmentConfig → Repository → RemoteEnvironment → Provider → Toolbox UI
```

**Key components in `plugin/src/main/kotlin/com/redhat/devtools/toolbox/`:**

- **DevSpacesRemoteDevExtension** (`DevSpacesPlugin.kt`): Entry point implementing `RemoteDevExtension`. Creates the provider and repository, wires up the data source.

- **DevSpacesRemoteProvider** (`DevSpacesRemoteProvider.kt`): `RemoteProvider` implementation. Handles URI callbacks (`jetbrains://gateway/com.redhat.devtools.toolbox?...`) from Dev Spaces Dashboard, triggering environment refresh and connection requests.

- **EnvironmentRepository** (`EnvironmentRepository.kt`): Manages environment lifecycle - fetches configs from data source, caches `RemoteEnvironment` instances, exposes reactive state via `MutableStateFlow`.

- **EnvironmentDataSource** (`datasource/`): Interface for fetching environment configs. `DevWorkspacesDataSource` is the real implementation.

- **DevSpacesRemoteEnvironment** (`environment/`): Wraps `EnvironmentConfig` with Toolbox API reactive properties (state, description, connection requests).

- **SshEnvironmentContentsViewFactory** (`environment/`): Creates SSH connection views. Current limitation: hardcoded local port 2022, supports only one active connection.
Comment thread
azatsarynnyy marked this conversation as resolved.

## Technical Notes

- The `group` value in `plugin/build.gradle.kts` (`com.redhat.devtools.toolbox`) is the provider ID used in URI handling.
- Plugin requires Java 21 (configured via `jvmToolchain(21)`).
- Uses JetBrains Toolbox Plugin API version defined in `gradle/libs.versions.toml`.
- Periodic environment polling is currently disabled to preserve environments received via external URLs (see TODO in `EnvironmentRepository.kt:61-67`).
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ package com.redhat.devtools.toolbox.buildlogic
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Classpath
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.TaskAction
Expand All @@ -29,6 +31,7 @@ class InstallToolboxPlugin : Plugin<Project> {
val installTask = target.tasks.register("installPlugin", InstallTask::class.java) {
extensionId.set(target.group.toString())
extensionJsonFile.set(target.layout.buildDirectory.file("generated/extension.json"))
pluginRuntimeDependencies.from(target.configurations.named("runtimeClasspath"))
}
installTask.configure { dependsOn(target.tasks.named("assemble")) }
}
Expand All @@ -41,6 +44,9 @@ class InstallToolboxPlugin : Plugin<Project> {
@get:InputFile
abstract val extensionJsonFile: RegularFileProperty

@get:Classpath
abstract val pluginRuntimeDependencies: ConfigurableFileCollection

@TaskAction
fun install() {
println("Installing Toolbox plugin...")
Expand All @@ -65,6 +71,7 @@ class InstallToolboxPlugin : Plugin<Project> {
// Copy jar task output and the generated JSON
from(project.tasks.getByName("jar"))
from(extensionJsonFile)
from(pluginRuntimeDependencies)

// Copy selected resources
from("src/main/resources") {
Expand Down
12 changes: 10 additions & 2 deletions plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
alias(libs.plugins.kotlin.jvm)
`kotlin-dsl`
// `kotlin-dsl`
id("com.redhat.devtools.toolbox.packaging")
id("com.redhat.devtools.toolbox.install")
id("com.redhat.devtools.toolbox.publish")
Expand All @@ -10,17 +10,25 @@ plugins {
// Note, the `group` value is used as a provider ID
// when handling the URLs like `jetbrains://gateway/provider.ID`
group = "com.redhat.devtools.toolbox"
version = "0.0.1"
version = "0.0.2"

extra["vendor"] = "Red-Hat"

configurations.named("runtimeClasspath") {
exclude(group = "org.jetbrains.kotlin")
exclude(group = "org.jetbrains.kotlinx")
}

kotlin {
jvmToolchain(21)
}

dependencies {
compileOnly(libs.bundles.toolbox.plugin.api)
compileOnly(libs.coroutines.core)
implementation("io.fabric8:openshift-client:7.6.1") {
exclude(group = "org.slf4j")
}
}

// Known issue with kotlin 2.1.0 when using MutableStateFlow, please remove
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import kotlinx.coroutines.CoroutineScope
import com.redhat.devtools.toolbox.datasource.EnvironmentDataSource
import com.redhat.devtools.toolbox.datasource.DevWorkspacesDataSource
import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory

/**
* Extends Toolbox remote development subsystem with
Expand All @@ -38,26 +39,28 @@ class DevSpacesRemoteDevExtension : RemoteDevExtension {
val coroutineScope = serviceLocator.getService(CoroutineScope::class.java)
val localizableStringFactory = serviceLocator.getService(LocalizableStringFactory::class.java)

val clientFactory = OpenShiftClientFactory(logger)

// Single data source, swap implementation as needed
val dataSource = createDataSource(logger)
val dataSource = createDataSource(logger, clientFactory)

// Initialized and manages the remote environments
val repository = EnvironmentRepository(
dataSource = dataSource,
coroutineScope = coroutineScope,
logger = logger,
localizableStringFactory = localizableStringFactory
localizableStringFactory = localizableStringFactory,
clientFactory = clientFactory
)

// Periodically refresh environments from the data source
repository.startPolling()

logger.info("DevSpacesRemoteProvider initialized with ${dataSource::class.simpleName}")
return DevSpacesRemoteProvider(repository, logger)
return DevSpacesRemoteProvider(repository, localizableStringFactory, logger)
}

private fun createDataSource(logger: Logger): EnvironmentDataSource {
// data source creation logic
return DevWorkspacesDataSource(logger = logger)
private fun createDataSource(logger: Logger, clientFactory: OpenShiftClientFactory): EnvironmentDataSource {
return DevWorkspacesDataSource(clientFactory, logger)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,35 @@
package com.redhat.devtools.toolbox

import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.util.LoadableState
import com.jetbrains.toolbox.api.localization.LocalizableString
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import com.jetbrains.toolbox.api.ui.components.UiPage
import com.jetbrains.toolbox.platform.image.ImageResource
import com.jetbrains.toolbox.platform.image.image
import com.jetbrains.toolbox.platform.resource.jvm.jvmResourceReader
import kotlinx.coroutines.flow.MutableStateFlow
import com.redhat.devtools.toolbox.environment.DevSpacesRemoteEnvironment
import com.redhat.devtools.toolbox.environment.EnvironmentConfig
import java.net.URI

/**
* [RemoteProvider] implementation that delegates the environment management to [EnvironmentRepository].
*/
class DevSpacesRemoteProvider(
val repository: EnvironmentRepository, val logger: Logger
) : RemoteProvider("Red Hat OpenShift Dev Spaces") {
val repository: EnvironmentRepository,
val localizableStringFactory: LocalizableStringFactory,
val logger: Logger
) : RemoteProvider("Dev Spaces") {

override val svgIcon: SvgIcon = SvgIcon(
this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf(),
type = SvgIcon.IconType.Default
)
override val iconResource: ImageResource = jvmResourceReader().image("/icon.svg")

override val noEnvironmentsDescription: String = "Start a workspace from Dev Spaces Dashboard"
override val noEnvironmentsDescription: String = "No DevWorkspaces found. Create a new one from the Dev Spaces Dashboard"
override val loadingEnvironmentsDescription: LocalizableString = localizableStringFactory.ptrl("Loading the Workspaces list...\r\n\r\n" +
"If the list does not load within a few seconds -\r\n" +
"make sure you are logged in to the correct cluster\r\n" +
"by running the 'oc login ...' command in the terminal.")

override val environments: MutableStateFlow<LoadableState<List<DevSpacesRemoteEnvironment>>> =
repository.environments
Expand All @@ -43,6 +50,8 @@ class DevSpacesRemoteProvider(

override fun setVisible(visibilityState: ProviderVisibilityState) {}

override fun getNewEnvironmentUiPage(): UiPage = UiPage(localizableStringFactory.pnotr("Choose a Workspace to connect to"))

/**
* Handles an external request, typically comes from Che/DevSpaces Dashboard via the link as:
* jetbrains://gateway/com.redhat.devtools.toolbox?...
Expand All @@ -58,25 +67,14 @@ class DevSpacesRemoteProvider(
} ?: emptyMap()

val dwID: String = queryParams["dwID"] ?: ""
val dwName: String = queryParams["dwName"] ?: ""
val userName: String = queryParams["username"] ?: ""
val sshKey: String = queryParams["key"] ?: ""
val project: String = queryParams["project"] ?: ""

// re-read the environments list with adding the additional env.
repository.refreshEnvironments(
EnvironmentConfig(
id = dwID,
name = MutableStateFlow(dwName),
username = userName,
sshKey = sshKey,
projectPaths = listOf(project),
)
)
if (dwID.isBlank()) {
logger.warn("Received URI without valid dwID parameter: $uri")
return
}

// Schedule connecting to a CDE from a Thin Client
// once the environment is added to Toolbox.
repository.updateConnectionRequest(dwID, true, "Error while connecting to remote")
// Schedule establishing the connection to the environment.
repository.updateConnectionRequest(dwID, true)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

override fun close() {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState
import com.redhat.devtools.toolbox.datasource.DataSourceException
import com.redhat.devtools.toolbox.datasource.EnvironmentDataSource
import com.redhat.devtools.toolbox.environment.*
import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

/**
* Repository managing the lifecycle of remote environments.
Expand All @@ -38,8 +40,9 @@ class EnvironmentRepository(
private val logger: Logger,
private val coroutineScope: CoroutineScope,
private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(),
private val refreshInterval: Duration = 10.minutes,
private val localizableStringFactory: LocalizableStringFactory
private val refreshInterval: Duration = 10.seconds,
private val localizableStringFactory: LocalizableStringFactory,
private val clientFactory: OpenShiftClientFactory
) {
// Internal mutable state
private val _environments = MutableStateFlow<LoadableState<List<DevSpacesRemoteEnvironment>>>(
Expand All @@ -57,27 +60,22 @@ class EnvironmentRepository(
// Initial fetch
refreshEnvironments()

// Periodic refresh
// TODO: enable it once fetching all workspaces implemented in DataSource
// Disabled to prevent loosing an environment came externally, through URL.
// while (isActive) {
// delay(refreshInterval)
// refreshEnvironments()
// }
// periodically sync the workspaces list with the remote
while (isActive) {
delay(refreshInterval)
refreshEnvironments()
}
}
}

/**
* Triggers updating the environments list.
*
* @param externalEnvironment - optional, may be provided in case of an external request.
* Typically, it comes from the Dashboard.
*/
suspend fun refreshEnvironments(externalEnvironment: EnvironmentConfig? = null) {
suspend fun refreshEnvironments() {
logger.debug("Refreshing environments from ${dataSource::class.simpleName}")

try {
val configs = dataSource.fetchEnvironments() + listOfNotNull(externalEnvironment)
val configs = dataSource.fetchEnvironments()

val environments = configs.map { config ->
getOrCreateEnvironment(config)
Expand Down Expand Up @@ -106,7 +104,7 @@ class EnvironmentRepository(
private fun getOrCreateEnvironment(config: EnvironmentConfig): DevSpacesRemoteEnvironment {
return environmentCache.getOrPut(config.id) {
logger.debug("Creating new environment: ${config.id}")
config.toRemoteEnvironment(contentsViewFactory, localizableStringFactory, logger)
config.toRemoteEnvironment(contentsViewFactory, localizableStringFactory, logger, clientFactory)
}.also { existingEnv ->
// Update config if it is changed
if (existingEnv.getConfig() != config) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,50 @@
package com.redhat.devtools.toolbox.datasource

import com.jetbrains.toolbox.api.core.diagnostics.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import com.redhat.devtools.toolbox.environment.EnvironmentConfig
import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory
import com.redhat.devtools.toolbox.openshift.DevWorkspaces
import com.redhat.devtools.toolbox.openshift.Projects
import kotlinx.coroutines.flow.MutableStateFlow

/**
* Data source returns the environment configurations from
* the DevWorkspaces fetched from a Dev Spaces instance.
* the DevWorkspaces fetched from the current Dev Spaces instance.
*/
class DevWorkspacesDataSource(
logger: Logger
private val clientFactory: OpenShiftClientFactory,
private val logger: Logger
) : EnvironmentDataSource {

/**
* Fetches the CDEs from the currently logged-in Dev Spaces instance.
*/
override suspend fun fetchEnvironments(): List<EnvironmentConfig> {
// TODO("Not yet implemented")
return emptyList()
}
return try {
clientFactory.create().use { client ->
val projects = Projects(client).list()

override fun handleExternalRequest(
id: String, name: String, userName: String, sshKey: String, projects: List<String>
): EnvironmentConfig {
return EnvironmentConfig(
id = id,
name = MutableStateFlow(name),
description = "[External] DevWorkspace",
username = userName,
sshKey = sshKey,
availableIdeProductCodes = listOf("IU"),
projectPaths = projects
)
projects
.mapNotNull { it.metadata?.name }
.flatMap { namespace ->
DevWorkspaces(client, logger).list(namespace)
}
.map { workspace ->
EnvironmentConfig(
id = workspace.id,
name = MutableStateFlow(workspace.name),
description = workspace.phase,
port = 2022, // the port of in-container running sshd
// availableIdeProductCodes = listOf("IU"),
// TODO: implement fetching the PROJECT_SOURCES env. var. value
projectPaths = listOf("/projects"),
tags = mapOf("namespace" to workspace.namespace)
)
}
}
} catch (e: Exception) {
logger.error("Failed to fetch environments: ${e.message}")
throw DataSourceException(e.message.toString(), e)
}
}
}
Loading