diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt index b8510955..8eb538ef 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt @@ -28,6 +28,7 @@ import com.redhat.devtools.gateway.server.RemoteIDEServer import com.redhat.devtools.gateway.server.RemoteIDEServerStatus import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.util.isServerContainerNotFound import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.models.V1Pod @@ -304,6 +305,8 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { remoteIdeServer.apply { waitServerReady(checkCancelled) }.getStatus(checkCancelled) }.getOrElse { e -> if (e.isCancellationException()) throw e + // no idea-server container, don't offer "restart pod" (CRW-11897). + if (e.isServerContainerNotFound()) throw e RemoteIDEServerStatus.empty() } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt index c30db453..4fa56c98 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway import com.intellij.openapi.util.IconLoader +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind import javax.swing.Icon object DevSpacesIcons { @@ -25,6 +26,22 @@ object DevSpacesIcons { private val WORKSPACE_TERMINATING = IconLoader.getIcon("/icons/stopping.svg", javaClass) private val WORKSPACE_FAILED = IconLoader.getIcon("/icons/failed.svg", javaClass) + private val EDITOR_VSCODE = IconLoader.getIcon("/icons/editors/vscode.svg", javaClass) + private val EDITOR_INTELLIJ_IDEA = IconLoader.getIcon("/icons/editors/intellij-idea.svg", javaClass) + private val EDITOR_JETBRAINS = IconLoader.getIcon("/icons/editors/jetbrains.svg", javaClass) + private val EDITOR_PYCHARM = IconLoader.getIcon("/icons/editors/pycharm.svg", javaClass) + private val EDITOR_CLION = IconLoader.getIcon("/icons/editors/clion.svg", javaClass) + private val EDITOR_GOLAND = IconLoader.getIcon("/icons/editors/goland.svg", javaClass) + private val EDITOR_PHPSTORM = IconLoader.getIcon("/icons/editors/phpstorm.svg", javaClass) + private val EDITOR_RIDER = IconLoader.getIcon("/icons/editors/rider.svg", javaClass) + private val EDITOR_RUBYMINE = IconLoader.getIcon("/icons/editors/rubymine.svg", javaClass) + private val EDITOR_WEBSTORM = IconLoader.getIcon("/icons/editors/webstorm.svg", javaClass) + private val EDITOR_CHEMUXER = IconLoader.getIcon("/icons/editors/chemuxer.svg", javaClass) + private val EDITOR_HERDR = IconLoader.getIcon("/icons/editors/herdr.svg", javaClass) + private val EDITOR_KIRO = IconLoader.getIcon("/icons/editors/kiro.svg", javaClass) + private val EDITOR_WEB_TERMINAL = IconLoader.getIcon("/icons/editors/web-terminal.svg", javaClass) + private val EDITOR_UNKNOWN = IconLoader.getIcon("/icons/editors/unknown.svg", javaClass) + fun getWorkspacePhaseIcon(phase: String): Icon? { /* * mimics what the web frontend is displaying. @@ -41,4 +58,24 @@ object DevSpacesIcons { } } + fun getEditorIcon(kind: WorkspaceEditorKind): Icon { + return when (kind) { + WorkspaceEditorKind.VSCODE -> EDITOR_VSCODE + WorkspaceEditorKind.INTELLIJ_IDEA -> EDITOR_INTELLIJ_IDEA + WorkspaceEditorKind.JETBRAINS -> EDITOR_JETBRAINS + WorkspaceEditorKind.PYCHARM -> EDITOR_PYCHARM + WorkspaceEditorKind.CLION -> EDITOR_CLION + WorkspaceEditorKind.GOLAND -> EDITOR_GOLAND + WorkspaceEditorKind.PHPSTORM -> EDITOR_PHPSTORM + WorkspaceEditorKind.RIDER -> EDITOR_RIDER + WorkspaceEditorKind.RUBYMINE -> EDITOR_RUBYMINE + WorkspaceEditorKind.WEBSTORM -> EDITOR_WEBSTORM + WorkspaceEditorKind.CHEMUXER -> EDITOR_CHEMUXER + WorkspaceEditorKind.HERDR -> EDITOR_HERDR + WorkspaceEditorKind.KIRO -> EDITOR_KIRO + WorkspaceEditorKind.WEB_TERMINAL -> EDITOR_WEB_TERMINAL + WorkspaceEditorKind.UNKNOWN -> EDITOR_UNKNOWN + } + } + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt index ed881bbc..6682e0e7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt @@ -14,8 +14,8 @@ package com.redhat.devtools.gateway.devworkspace import com.google.gson.reflect.TypeToken import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.openshift.Utils +import com.redhat.devtools.gateway.openshift.isDevWorkspaceCrdMissing import com.redhat.devtools.gateway.openshift.isRetryable -import com.redhat.devtools.gateway.openshift.shouldBeIgnored import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi @@ -26,9 +26,23 @@ import kotlinx.coroutines.withTimeoutOrNull import java.io.IOException import java.util.concurrent.CancellationException +data class DevWorkspaceListItem( + val workspace: DevWorkspace, + val editor: WorkspaceEditorInfo, +) + data class DevWorkspaceListResult( - val items: List, - val resourceVersion: String? + val items: List, + val resourceVersion: String?, + val templates: Map> = emptyMap(), + // True when the template list request failed with 401/403/404 (error ignored), so templates are unavailable. + // An empty map alone does not imply this. + val templatesUnavailable: Boolean = false +) + +data class Templates( + val map: Map>, + val unavailable: Boolean // true when 401/403/404 ) val DevWorkspace.cheEditor: String @@ -40,8 +54,6 @@ class DevWorkspaces(private val client: ApiClient) { private val customApi = CustomObjectsApi(client) companion object { - private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE) - const val FAILED: String = "Failed" const val RUNNING: String = "Running" const val STOPPED: String = "Stopped" @@ -53,77 +65,41 @@ class DevWorkspaces(private val client: ApiClient) { @Throws(ApiException::class) fun listWithResult(namespace: String): DevWorkspaceListResult { - try { - val response = customApi.listNamespacedCustomObject( + val response = try { + customApi.listNamespacedCustomObject( "workspace.devfile.io", "v1alpha2", namespace, "devworkspaces" ).execute() - - val devWorkspaceTemplateMap = getTemplateMap(namespace) - val dwItems = Utils.getValue(response, arrayOf("items")) as List<*> - val dwList = dwItems - .map { dwItem -> DevWorkspace.from(dwItem) } - .filter { isIdeaEditorBased(it, devWorkspaceTemplateMap) } - val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?) - - return DevWorkspaceListResult(dwList, lastResourceVersion) } catch (e: ApiException) { - thisLogger().info(e.message) - - return when (e.code) { - 403, 404 -> { - // There might be some namespaces (OpenShift projects) in which the user cannot list resource "devworkspaces" - // e.g. "openshift-virtualization-os-images" on Red Hat Dev Sandbox, or the given cluster doesn't have - // the RedHat DevSpaces operator installed on it, etc. - // - // It doesn't make sense to show an error to the user in such cases, - // so let's skip it silently. - DevWorkspaceListResult(emptyList(), null) - } - else -> { - thisLogger().error("Kubernetes API error ${e.code}", e) - throw e - } + if (e.isSkippableNamespaceForDevWorkspaceListing(namespace)) { + thisLogger().info("Ignored: ${e.message}") + return DevWorkspaceListResult(emptyList(), null) + } else { + thisLogger().error("Kubernetes API error ${e.code}", e) + throw e } } + + val templates = loadTemplates(namespace) + val dwItems = Utils.getValue(response, arrayOf("items")) as List<*> + val dwList = dwItems + .map { dwItem -> DevWorkspace.from(dwItem) } + .map { dw -> DevWorkspaceListItem(dw, WorkspaceEditorInfoProvider.create(dw, templates.map)) } + val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?) + + return DevWorkspaceListResult( + dwList, + lastResourceVersion, + templates.map, + templatesUnavailable = templates.unavailable + ) } @Throws(ApiException::class) fun list(namespace: String): List { - return listWithResult(namespace).items - } - - fun isIdeaEditorBased(devWorkspace: DevWorkspace, devWorkspaceTemplateMap: Map>): Boolean { - // Quick editor ID check - if (devWorkspace.cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) { - return true - } - - // DevWorkspace Template check - val templates = devWorkspaceTemplateMap[devWorkspace.uid] ?: return false - return templates.any { template -> - @Suppress("UNCHECKED_CAST") - val components = template.components as? List ?: return@any false - components.any { component: Any -> - val map = component as? Map<*, *> ?: return@any false - val volume = map["volume"] as? Map<*, *> - // Check 'volume.name' first (v1alpha1), fallback to top-level 'name' (v1alpha2) - val name = volume?.get("name") as? String ?: map["name"] as? String - name.equals("idea-server", ignoreCase = true) - } - } - } - - // Creates a filter for the Idea-based DevWorkspaces - fun createIdeaEditorFilter( - namespace: String - ): (DevWorkspace) -> Boolean { - val templateMap = getTemplateMap(namespace) - return { dw: DevWorkspace -> - isIdeaEditorBased(dw, templateMap) - } + return listWithResult(namespace).items.map { it.workspace } } fun get(namespace: String, name: String): DevWorkspace { @@ -137,8 +113,18 @@ class DevWorkspaces(private val client: ApiClient) { return DevWorkspace.from(dwObj) } - // Returns a map of DW Owner UID tp list of DW Templates - private fun getTemplateMap(namespace: String): Map> { + /** + * Loads all DevWorkspaceTemplates for the given namespace and groups them by their owner reference UID. + * + * Queries the Kubernetes API for `devworkspacetemplates` resources in the specified namespace, + * parses each template, and builds a map from owner UID to the list of templates owned by that UID. + * + * If the API returns a 401/403/404, returns an empty map with `unavailable = true`. + * + * @param namespace the Kubernetes namespace to list templates from + * @return a [Templates] containing the UID-to-templates map and an availability flag + */ + fun loadTemplates(namespace: String): Templates { try { val dwTemplateList = customApi .listNamespacedCustomObject( @@ -150,7 +136,7 @@ class DevWorkspaces(private val client: ApiClient) { .execute() val items = Utils.getValue(dwTemplateList, arrayOf("items")) as? List<*> ?: emptyList() - return items + val map = items .map { DevWorkspaceTemplate.from(it) } .flatMap { templ -> templ.ownerRefencesUids.map { uid -> uid to templ } @@ -159,9 +145,10 @@ class DevWorkspaces(private val client: ApiClient) { keySelector = { it.first }, // UID valueTransform = { it.second } // DevWorkspaceTemplate ) + return Templates(map, unavailable = false) } catch (e: ApiException) { - if (e.shouldBeIgnored()) { - return emptyMap() + if (e.isIgnorableTemplateListError()) { + return Templates(emptyMap(), unavailable = true) } thisLogger().info(e.message) throw e @@ -324,4 +311,33 @@ class DevWorkspaces(private val client: ApiClient) { object : TypeToken>() {}.type ) } + +/** Returns `true` if the given exception is ignorable when listing templates. + * Returns `false` otherwise. + * Template list failures with 401, 403, or 404 are silently degraded to an empty + * map with [Templates.unavailable] set to true. + * + * Note: 401 is ignorable for templates because templates are optional metadata + * (editor detection falls back to annotation). However, 401 for devworkspaces + * listing is NOT ignorable and rethrows — see [isSkippableNamespaceForDevWorkspaceListing]. + */ + private fun ApiException.isIgnorableTemplateListError(): Boolean = + code == 401 || code == 403 || code == 404 + + /** Returns `true` if the given exception is skippable when listing devworkspaces + * for a specific namespace during multi-namespace scanning. + * Returns `false` otherwise. + * Skippable errors: CRD missing (404 with CRD-not-found response body), + * 403 (Forbidden), or plain 404 — the namespace has no DevSpaces/DevWorkspaces + * resources or access is denied for system namespaces in multi-namespace scans. + * Non-skippable: 401 (Unauthorized) propagates/rethrows, and other errors + * propagate normally. + */ + private fun ApiException.isSkippableNamespaceForDevWorkspaceListing(namespace: String): Boolean = when { + isDevWorkspaceCrdMissing() -> true + code == 403 -> true + code == 404 -> true + else -> false + } + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt new file mode 100644 index 00000000..a29da367 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.devworkspace + +import com.redhat.devtools.gateway.openshift.Utils + +enum class WorkspaceEditorKind { + VSCODE, + INTELLIJ_IDEA, + PYCHARM, + CLION, + GOLAND, + PHPSTORM, + RIDER, + RUBYMINE, + WEBSTORM, + CHEMUXER, + HERDR, + KIRO, + WEB_TERMINAL, + JETBRAINS, + UNKNOWN, +} + +data class WorkspaceEditorInfo( + val kind: WorkspaceEditorKind, + val tooltip: String, +) + +private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE) + +object WorkspaceEditorInfoProvider { + + fun create( + devWorkspace: DevWorkspace, + templateMap: Map> + ): WorkspaceEditorInfo { + val cheEditor = Utils.getValue(devWorkspace.annotations, arrayOf("che.eclipse.org/che-editor")) as? String + if (!cheEditor.isNullOrBlank()) { + return createFromAnnotation(cheEditor) + } + if (isJetBrainsEditor(devWorkspace, templateMap)) { + return WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") + } + return WorkspaceEditorInfo(WorkspaceEditorKind.UNKNOWN, "Unknown Editor") + } + + fun isJetBrainsEditor( + devWorkspace: DevWorkspace, + templateMap: Map> + ): Boolean { + // DevWorkspace Template check + val templates = templateMap[devWorkspace.uid] ?: return false + return templates.any { template -> + @Suppress("UNCHECKED_CAST") + val components = template.components as? List ?: return@any false + components.any { component: Any -> + val map = component as? Map<*, *> ?: return@any false + val volume = map["volume"] as? Map<*, *> + // Check 'volume.name' first (v1alpha1), fallback to top-level 'name' (v1alpha2) + val name = volume?.get("name") as? String ?: map["name"] as? String + name.equals("idea-server", ignoreCase = true) + } + } + } + + private fun createFromAnnotation(cheEditor: String): WorkspaceEditorInfo { + val editorName = extractEditorName(cheEditor) + if (editorName != null) { + createFromEditorName(editorName)?.let { return it } + } + if (cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) { + return WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") + } + val fallbackSegment = cheEditor.split("/").lastOrNull { it.isNotBlank() } + return WorkspaceEditorInfo(WorkspaceEditorKind.UNKNOWN, fallbackSegment ?: "Unknown Editor") + } + + private fun extractEditorName(cheEditor: String): String? { + val parts = cheEditor.split("/").filter { it.isNotBlank() } + if (parts.size >= 3) { + return parts[1] + } + return parts.firstOrNull { CHE_EDITOR_ID_REGEX.matches(it) || it.startsWith("che-", ignoreCase = true) } + } + + private fun createFromEditorName(editorName: String): WorkspaceEditorInfo? { + val lowercase = editorName.lowercase() + return when { + lowercase.contains("che-code") -> WorkspaceEditorInfo(WorkspaceEditorKind.VSCODE, "VS Code - Open Source") + lowercase.contains("che-idea") -> WorkspaceEditorInfo( + WorkspaceEditorKind.INTELLIJ_IDEA, + "IntelliJ IDEA Ultimate (desktop)" + ) + lowercase.contains("che-pycharm") -> + WorkspaceEditorInfo(WorkspaceEditorKind.PYCHARM, "PyCharm") + lowercase.contains("che-clion") -> + WorkspaceEditorInfo(WorkspaceEditorKind.CLION, "JetBrains CLion (desktop)") + lowercase.contains("che-goland") -> + WorkspaceEditorInfo(WorkspaceEditorKind.GOLAND, "JetBrains GoLand (desktop)") + lowercase.contains("che-phpstorm") -> + WorkspaceEditorInfo(WorkspaceEditorKind.PHPSTORM, "JetBrains PhpStorm (desktop)") + lowercase.contains("che-rider") -> + WorkspaceEditorInfo(WorkspaceEditorKind.RIDER, "JetBrains Rider (desktop)") + lowercase.contains("che-rubymine") -> + WorkspaceEditorInfo(WorkspaceEditorKind.RUBYMINE, "JetBrains RubyMine (desktop)") + lowercase.contains("che-webstorm") -> + WorkspaceEditorInfo(WorkspaceEditorKind.WEBSTORM, "JetBrains WebStorm (desktop)") + lowercase.contains("che-chemuxer") -> + WorkspaceEditorInfo(WorkspaceEditorKind.CHEMUXER, "Chemuxer") + lowercase.contains("che-herdr") -> + WorkspaceEditorInfo(WorkspaceEditorKind.HERDR, "Herdr") + lowercase.contains("che-kiro") -> + WorkspaceEditorInfo(WorkspaceEditorKind.KIRO, "Kiro (desktop)") + lowercase.contains("che-web-terminal") -> + WorkspaceEditorInfo(WorkspaceEditorKind.WEB_TERMINAL, "Web Terminal") + CHE_EDITOR_ID_REGEX.matches(editorName) -> + WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") + else -> null + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt new file mode 100644 index 00000000..5f91006b --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.devworkspace + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeLater +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Resolves [WorkspaceEditorInfo] for watched DevWorkspaces, caching templates per namespace + * and coalescing background template fetches (one in-flight fetch per namespace). + * After a successful fetch, [onEditorResolved] is invoked with the freshly resolved editor + * of the triggering workspace plus all other workspaces that still resolve to UNKNOWN in + * that namespace, so coalesced/pending workspaces are not left stale. + */ +internal class WorkspaceEditorResolver( + private val devWorkspaces: DevWorkspaces, + private val scope: CoroutineScope, + private val onEditorResolved: (List>) -> Unit, + private val dispatchEdt: (() -> Unit) -> Unit = { action -> + invokeLater(ModalityState.any(), action) + } +) { + @Volatile + var templateMapsByNamespace: Map>> = emptyMap() + private val templatesUnavailableNamespaces: MutableSet = ConcurrentHashMap.newKeySet() + private val templateFetchInFlight: ConcurrentHashMap = ConcurrentHashMap() + private val trackedWorkspaces: ConcurrentHashMap = ConcurrentHashMap() + + private fun workspaceKey(dw: DevWorkspace): String = "${dw.namespace}/${dw.name}" + + fun seedTemplateCache( + mapsByNamespace: Map>>, + unavailableNamespaces: Set + ) { + templateMapsByNamespace = mapsByNamespace + templatesUnavailableNamespaces.clear() + templatesUnavailableNamespaces.addAll(unavailableNamespaces) + } + + fun templatesUnavailable(namespace: String): Boolean = namespace in templatesUnavailableNamespaces + + fun resolve(dw: DevWorkspace): WorkspaceEditorInfo { + trackedWorkspaces[workspaceKey(dw)] = dw + val templateMap = templateMapsByNamespace[dw.namespace] ?: emptyMap() + return WorkspaceEditorInfoProvider.create(dw, templateMap) + } + + fun untrack(dw: DevWorkspace) { + trackedWorkspaces.remove(workspaceKey(dw)) + } + + fun refreshTracked(dw: DevWorkspace) { + trackedWorkspaces[workspaceKey(dw)] = dw + } + + fun backgroundFetchTemplatesAndPatch(dw: DevWorkspace) { + val ns = dw.namespace + // Atomic coalesce: only one in-flight fetch per namespace. + scope.launch { + val thisJob = coroutineContext[Job]!! + if (!acquireFetchSlot(ns, thisJob)) { + return@launch + } + try { + val templates = devWorkspaces.loadTemplates(ns) + if (templates.unavailable) { + templatesUnavailableNamespaces.add(ns) + return@launch + } + applyTemplates(ns, templates) + dispatchEdt { + onEditorResolved(computePatches(dw, ns, templates.map)) + } + } finally { + releaseFetchSlot(ns, thisJob) + } + } + } + + private fun acquireFetchSlot(ns: String, job: Job): Boolean = + templateFetchInFlight.putIfAbsent(ns, job) == null + + private fun releaseFetchSlot(ns: String, job: Job) { + templateFetchInFlight.remove(ns, job) + } + + private fun applyTemplates(ns: String, templates: Templates) { + templateMapsByNamespace = templateMapsByNamespace + (ns to templates.map) + templatesUnavailableNamespaces.remove(ns) + } + + private fun computePatches( + dw: DevWorkspace, + ns: String, + templateMap: Map> + ): List> { + val patches = mutableListOf>() + val latestDw = trackedWorkspaces[workspaceKey(dw)] ?: dw + val editorInfo = WorkspaceEditorInfoProvider.create(latestDw, templateMap) + if (editorInfo.kind != WorkspaceEditorKind.UNKNOWN) { + patches += latestDw to editorInfo + } + val triggerKey = workspaceKey(latestDw) + patches += trackedWorkspaces.values + .filter { it.namespace == ns && workspaceKey(it) != triggerKey } + .map { it to WorkspaceEditorInfoProvider.create(it, templateMap) } + .filter { (_, editor) -> editor.kind != WorkspaceEditorKind.UNKNOWN } + return patches + } + + fun cancelInFlight() { + templateFetchInFlight.values.forEach { it.cancel() } + templateFetchInFlight.clear() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index e8dddbde..f69c18a4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -123,8 +123,6 @@ fun ApiException.codeToReasonPhrase(): String = statusCodeReasonPhrase(code) /** Converts HTTP status code to human-readable message. */ fun Int.reasonPhrase(): String = statusCodeReasonPhrase(this) -fun ApiException.shouldBeIgnored(): Boolean = - code == 403 || code == 404 fun ApiException.isRetryable(): Boolean = code in setOf(429, 500, 502, 503, 504) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt index 3d2173cb..f68f71a3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt @@ -71,7 +71,12 @@ internal class PodExecSession( ctx.streamsReady.await() listOfNotNull(ctx.stdoutJobRef.get(), ctx.stderrJobRef.get()).joinAll() checkCancelled?.invoke() - val code = ctx.exitCode.await() + + // Shut down the exec client before resuming the caller so that cleanup + // is complete once exec() returns or throws + val outcome = runCatching { ctx.exitCode.await() } + shutdownExecClient(ctx.execClient) + val code = outcome.getOrThrow() checkCancelled?.invoke() val stderrMsg = ctx.stderr.toString().takeIf { it.isNotBlank() } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index abf1a06d..1bb44959 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -22,6 +22,13 @@ import kotlinx.coroutines.* import java.io.IOException import java.util.concurrent.CancellationException +/** + * Thrown when the workspace pod no longer contains an idea-server container. + * This is a terminal condition — the workspace is unusable — so it must fail fast + * instead of being retried until the ready timeout elapses (CRW-11897). + */ +class ServerContainerNotFoundException(message: String) : IOException(message) + /** * Represent an IDE server running in a CDE. */ @@ -99,6 +106,34 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { } } + /** + * Re-resolves the workspace pod and idea-server container. + * + * @return `true` when refreshed successfully, `false` on transient failures (retried). + * Terminal conditions (cancellation, missing idea-server container) are rethrown. + */ + @Throws(CancellationException::class) + private fun refreshPod(refreshFailures: IntArray): Boolean { + return try { + pod = findPod() + container = findContainer() + refreshFailures[0] = 0 + true + } catch (e: Exception) { + if (e.isCancellationException()) throw e + if (e is ServerContainerNotFoundException) throw e + refreshFailures[0]++ + thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) + if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { + thisLogger().warn( + "Pod/container refresh has failed ${refreshFailures[0]} consecutive times; " + + "stale pod references may cause incorrect status checks" + ) + } + false + } + } + @Throws(CancellationException::class) private suspend fun isServerState( isReadyState: Boolean, @@ -107,26 +142,14 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { refreshFailures: IntArray = intArrayOf(0), ): Boolean { return try { - if (refreshPodBeforeCheck) { - runCatching { - pod = findPod() - container = findContainer() - }.onFailure { e -> - if (e.isCancellationException()) throw e - refreshFailures[0]++ - thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) - if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { - thisLogger().warn( - "Pod/container refresh has failed ${refreshFailures[0]} consecutive times; " + - "stale pod references may cause incorrect status checks" - ) - } - return false - }.onSuccess { refreshFailures[0] = 0 } + // Re-resolve pod while waiting for ready so a recycled pod is not missed. + if (refreshPodBeforeCheck && !refreshPod(refreshFailures)) { + return false } getStatus(checkCancelled).isReady == isReadyState } catch (e: Exception) { if (e.isCancellationException()) throw e + if (e is ServerContainerNotFoundException) throw e thisLogger().debug("Failed to check workspace IDE state.", e) false } @@ -192,7 +215,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { return pod.spec!!.containers.find { container -> container.ports?.any { port -> port.name == "idea-server" } != null } - ?: throw IOException( + ?: throw ServerContainerNotFoundException( "Workspace IDE container not found in the Pod: ${pod.metadata?.name}" ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index dfa37d89..329caf8b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.util import com.redhat.devtools.gateway.auth.session.SsoLoginException +import com.redhat.devtools.gateway.server.ServerContainerNotFoundException import kotlinx.coroutines.TimeoutCancellationException import java.util.concurrent.CancellationException import java.util.concurrent.TimeoutException @@ -25,6 +26,15 @@ fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationExcept fun Throwable.isCancellationException(): Boolean = (this is CancellationException && !isTimeoutException() ) +/** + * Returns true if this throwable or any of its causes is a `ServerContainerNotFoundException`. + * Traverses the exception chain to determine if the root cause is a missing server container. + * + * @return true if a `ServerContainerNotFoundException` is found in the exception chain, false otherwise + */ +fun Throwable.isServerContainerNotFound(): Boolean = + generateSequence(this) { it.cause }.any { it is ServerContainerNotFoundException } + fun Throwable.isLoginUserCancelled(): Boolean = generateSequence(this) { it.cause }.any { it is SsoLoginException.Cancelled } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt index f77cecb1..42c4ab13 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt @@ -11,49 +11,69 @@ */ package com.redhat.devtools.gateway.view.steps -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.invokeLater import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.ColoredListCellRenderer -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.ui.dsl.builder.* import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeLater import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesConnection import com.redhat.devtools.gateway.DevSpacesContext -import com.redhat.devtools.gateway.DevSpacesIcons import com.redhat.devtools.gateway.devworkspace.DevWorkspace -import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListener -import com.redhat.devtools.gateway.devworkspace.DevWorkspaceWatchManager +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem import com.redhat.devtools.gateway.devworkspace.DevWorkspaces +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceTemplate +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.Utils import com.redhat.devtools.gateway.server.RemoteIDEServer import com.redhat.devtools.gateway.server.RemoteIDEServerStatus import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.util.isServerContainerNotFound import com.redhat.devtools.gateway.util.messageWithoutPrefix +import com.redhat.devtools.gateway.view.steps.workspaces.DevWorkspaceTableModel +import com.redhat.devtools.gateway.view.steps.workspaces.DevWorkspacesTable +import com.redhat.devtools.gateway.view.steps.workspaces.WorkspacesWatch import com.redhat.devtools.gateway.view.ui.Dialogs +import com.redhat.devtools.gateway.view.ui.Dialogs.confirmUnknownEditor import com.redhat.devtools.gateway.view.ui.onDoubleClick -import io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import java.awt.Dimension -import java.awt.FontMetrics import java.util.concurrent.CancellationException -import javax.swing.DefaultListModel import javax.swing.JButton -import javax.swing.JList -import javax.swing.ListModel import javax.swing.event.ListSelectionEvent import javax.swing.event.ListSelectionListener +import javax.swing.event.TableModelEvent +import javax.swing.event.TableModelListener + +private const val NO_JETBRAINS_IDE_CONTAINER_MESSAGE = + "The workspace does not have a JetBrains IDE (idea-server) container, so it cannot be connected to." + +private fun WorkspaceEditorKind.isConnectableEditor(): Boolean { + return when (this) { + WorkspaceEditorKind.INTELLIJ_IDEA, + WorkspaceEditorKind.PYCHARM, + WorkspaceEditorKind.CLION, + WorkspaceEditorKind.GOLAND, + WorkspaceEditorKind.PHPSTORM, + WorkspaceEditorKind.RIDER, + WorkspaceEditorKind.RUBYMINE, + WorkspaceEditorKind.WEBSTORM, + WorkspaceEditorKind.HERDR, + WorkspaceEditorKind.KIRO, + WorkspaceEditorKind.JETBRAINS, + WorkspaceEditorKind.UNKNOWN -> + true + else -> false + } +} val DevWorkspace.displayName: String get() { @@ -69,8 +89,8 @@ class DevSpacesWorkspacesStepView( override val previousActionText = DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.previous") - private var listDWDataModel = DefaultListModel() - private var listDevWorkspaces = JBList(listDWDataModel) + private var devWorkspacesTableModel = DevWorkspaceTableModel() + private var devWorkspacesTable = DevWorkspacesTable(devWorkspacesTableModel) private lateinit var startDevWorkspaceButton: JButton private lateinit var stopDevWorkspaceButton: JButton @@ -85,7 +105,7 @@ class DevSpacesWorkspacesStepView( } row { - cell(JBScrollPane(listDevWorkspaces) + cell(JBScrollPane(devWorkspacesTable) .apply { preferredSize = Dimension(preferredSize.width, 200) minimumSize = Dimension(minimumSize.width, 100) @@ -117,17 +137,16 @@ class DevSpacesWorkspacesStepView( } override fun onInit() { - listDWDataModel.clear() // avoid glitch where user would see old list content before it's cleared - listDevWorkspaces.selectionModel.addListSelectionListener(DevWorkspaceSelection()) - listDevWorkspaces.onDoubleClick { + devWorkspacesTableModel.clear() // avoid glitch where user would see old list content before it's cleared + devWorkspacesTable.selectionModel.addListSelectionListener(DevWorkspaceSelection()) + devWorkspacesTable.onDoubleClick { onNext() } - listDevWorkspaces.cellRenderer = DevWorkspaceListRenderer() - listDevWorkspaces.setEmptyText(DevSpacesBundle.message("connector.wizard_step.remote_server_connection.list.empty_text")) - initListListeners(this) + initTableListeners(this) - watchManager = WorkspacesWatch(devSpacesContext.client, listDWDataModel) + watchManager?.dispose() + watchManager = WorkspacesWatch(devSpacesContext.client, devWorkspacesTableModel) refreshAndWatchAllDevWorkspaces() enableButtons() } @@ -138,10 +157,19 @@ class DevSpacesWorkspacesStepView( } override fun onNext(): Boolean { - val workspace = getSelectedWorkspace() ?: return false + val item = getSelectedWorkspaceListItem() ?: return false + val workspace = item.workspace + if (!item.editor.kind.isConnectableEditor()) { + return false + } if (!isRunning(workspace)) { return false } + if (item.editor.kind == WorkspaceEditorKind.UNKNOWN) { + if (!confirmUnknownEditor()) { + return false + } + } devSpacesContext.devWorkspace = workspace try { getServerStatus() @@ -150,6 +178,11 @@ class DevSpacesWorkspacesStepView( return false // canceled, stay on this step } thisLogger().error("Could not check workspace IDE status", e) + if (e.isServerContainerNotFound()) { + // do not offer restart pod + Dialogs.error(NO_JETBRAINS_IDE_CONTAINER_MESSAGE, "Cannot Connect to Workspace IDE") + return false + } if (Dialogs.ideNotResponding()) { stopDevWorkspace() connect() @@ -161,20 +194,18 @@ class DevSpacesWorkspacesStepView( return false // Stay on this step after connection } - private fun initListListeners(disposable: Disposable) { + private fun initTableListeners(disposable: Disposable) { val selectionListener = ListSelectionListener { enableButtons() } - listDevWorkspaces.addListSelectionListener(selectionListener) + devWorkspacesTable.selectionModel.addListSelectionListener(selectionListener) - val dataListener = object : javax.swing.event.ListDataListener { - override fun intervalAdded(e: javax.swing.event.ListDataEvent) = enableButtons() - override fun intervalRemoved(e: javax.swing.event.ListDataEvent) = enableButtons() - override fun contentsChanged(e: javax.swing.event.ListDataEvent) = enableButtons() + val dataListener = object : TableModelListener { + override fun tableChanged(e: TableModelEvent) = enableButtons() } - listDWDataModel.addListDataListener(dataListener) + devWorkspacesTableModel.addTableModelListener(dataListener) Disposer.register(disposable) { - listDevWorkspaces.removeListSelectionListener(selectionListener) - listDWDataModel.removeListDataListener(dataListener) + devWorkspacesTable.selectionModel.removeListSelectionListener(selectionListener) + devWorkspacesTableModel.removeTableModelListener(dataListener) } } @@ -201,100 +232,100 @@ class DevSpacesWorkspacesStepView( private fun refreshAllDevWorkspaces(): Map { val lastResourceVersions = mutableMapOf() + val templateMaps = mutableMapOf>>() + val namespacesUnavailable = mutableSetOf() val devWorkspaces = Projects(devSpacesContext.client).list() .map { Utils.getValue(it, arrayOf("metadata", "name")) as String } .flatMap { namespace -> val dwListResult = DevWorkspaces(devSpacesContext.client).listWithResult(namespace) lastResourceVersions[namespace] = dwListResult.resourceVersion + templateMaps[namespace] = dwListResult.templates + if (dwListResult.templatesUnavailable) { + namespacesUnavailable.add(namespace) + } dwListResult.items } - invokeLater( - ModalityState.any(), - { - val selectedIndex = listDevWorkspaces.selectedIndex - listDWDataModel.apply { - clear() - addAll(devWorkspaces) - } - listDevWorkspaces.selectedIndex = getValidSelectedIndex(selectedIndex) + invokeLater(ModalityState.any()) { + val selectedRow = devWorkspacesTable.selectedRow + devWorkspacesTableModel.apply { + clear() + addAll(devWorkspaces) } - ) + devWorkspacesTable.updateColumnWidths() + val newSelection = getValidSelectedIndex(selectedRow) + if (newSelection >= 0) { + devWorkspacesTable.setRowSelectionInterval(newSelection, newSelection) + } + } + + watchManager?.seedTemplateCache(templateMaps, namespacesUnavailable) return lastResourceVersions } private fun getValidSelectedIndex(selectedIndex: Int): Int { return if (selectedIndex >= 0 - && selectedIndex < listDWDataModel.size) { + && selectedIndex < devWorkspacesTableModel.getRowCount()) { selectedIndex } else { - if (listDWDataModel.size > 0) 0 else -1 + if (devWorkspacesTableModel.getRowCount() > 0) 0 else -1 } } private fun refreshDevWorkspace(namespace: String, name: String) { val refreshedDevWorkspace = DevWorkspaces(devSpacesContext.client).get(namespace, name) - - invokeLater( - ModalityState.any(), - { - listDWDataModel - .indexOf(refreshedDevWorkspace) - .also { - if (it != -1) listDWDataModel[it] = refreshedDevWorkspace - } - } - ) - } - - private fun startDevWorkspace() { - val selectedWorkspace = getSelectedWorkspace() ?: return - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - try { - DevWorkspaces(devSpacesContext.client).start( - selectedWorkspace.namespace, - selectedWorkspace.name - ) - refreshDevWorkspace( - selectedWorkspace.namespace, - selectedWorkspace.name + invokeLater(ModalityState.any()) { + val idx = devWorkspacesTableModel.indexOfFirst { it.workspace.namespace == namespace && it.workspace.name == name } + if (idx != -1) { + // Keep the previously resolved editor: the freshly fetched DevWorkspace has no template + // context here, so a template-based JetBrains icon must not flip to Unknown (CRW-11897). + devWorkspacesTableModel.set( + idx, + DevWorkspaceListItem( + refreshedDevWorkspace, + devWorkspacesTableModel[idx].editor ) - enableButtons() - } catch (e: Exception) { - thisLogger().error("Failed to start workspace", e) - // UI already shows current state, just enable buttons - enableButtons() - } - }, - "Starting Workspace", - true, - null - ) + ) + } else { + thisLogger().debug( + "refreshDevWorkspace: $namespace/$name not in list model; skipping UI update" + ) + } + } } - private fun stopDevWorkspace() { + private fun startDevWorkspace() = runWorkspaceAction( + verb = { namespace, name -> DevWorkspaces(devSpacesContext.client).start(namespace, name) }, + actionError = "Failed to start workspace", + progressTitle = "Starting Workspace" + ) + + private fun stopDevWorkspace() = runWorkspaceAction( + verb = { namespace, name -> DevWorkspaces(devSpacesContext.client).stop(namespace, name) }, + actionError = "Failed to stop workspace", + progressTitle = "Stopping Workspace" + ) + + private fun runWorkspaceAction( + verb: (String, String) -> Unit, + actionError: String, + progressTitle: String + ) { val selectedWorkspace = getSelectedWorkspace() ?: return ProgressManager.getInstance().runProcessWithProgressSynchronously( { try { - DevWorkspaces(devSpacesContext.client).stop( - selectedWorkspace.namespace, - selectedWorkspace.name - ) - refreshDevWorkspace( - selectedWorkspace.namespace, - selectedWorkspace.name - ) + verb(selectedWorkspace.namespace, selectedWorkspace.name) + refreshDevWorkspace(selectedWorkspace.namespace, selectedWorkspace.name) enableButtons() } catch (e: Exception) { - thisLogger().error("Failed to stop workspace", e) + thisLogger().error(actionError, e) // UI already shows current state, just enable buttons enableButtons() } }, - "Stopping Workspace", + progressTitle, true, null ) @@ -361,35 +392,26 @@ class DevSpacesWorkspacesStepView( try { runBlocking(Dispatchers.IO) { DevSpacesConnection(devSpacesContext).connect( - { - refreshDevWorkspace( - devSpacesContext.devWorkspace.namespace, - devSpacesContext.devWorkspace.name - ) - enableButtons() - }, - { - enableButtons() - }, + { refreshSelectedAndButtons() }, + { enableButtons() }, { if (waitDevWorkspaceStopped(devSpacesContext.devWorkspace)) { - refreshDevWorkspace( - devSpacesContext.devWorkspace.namespace, - devSpacesContext.devWorkspace.name - ) - enableButtons() + refreshSelectedAndButtons() } } ) } } catch (e: Exception) { - refreshDevWorkspace( - devSpacesContext.devWorkspace.namespace, - devSpacesContext.devWorkspace.name - ) - enableButtons() + refreshSelectedAndButtons() thisLogger().error("Workspace IDE connection failed.", e) - Dialogs.error(e.messageWithoutPrefix() ?: "Could not connect to workspace IDE", "Connection Error") + if (e.isServerContainerNotFound()) { + Dialogs.error(NO_JETBRAINS_IDE_CONTAINER_MESSAGE, "Cannot Connect to Workspace IDE") + } else { + Dialogs.error( + e.messageWithoutPrefix() ?: "Could not connect to workspace IDE", + "Connection Error" + ) + } } }, DevSpacesBundle.message("connector.loader.devspaces.connecting.text"), @@ -398,6 +420,11 @@ class DevSpacesWorkspacesStepView( ) } + private fun refreshSelectedAndButtons() { + refreshDevWorkspace(devSpacesContext.devWorkspace.namespace, devSpacesContext.devWorkspace.name) + enableButtons() + } + private fun waitDevWorkspaceStopped(devWorkspace: DevWorkspace): Boolean { return runBlocking { DevWorkspaces(devSpacesContext.client) .waitPhase( @@ -409,42 +436,23 @@ class DevSpacesWorkspacesStepView( } private fun enableButtons() { - invokeLater( - ModalityState.any(), - { - val workspace = getSelectedWorkspace() + invokeLater(ModalityState.any()) { + val workspace = getSelectedWorkspace() - startDevWorkspaceButton.isEnabled = isStopped(workspace) - stopDevWorkspaceButton.isEnabled = isRunning(workspace) + startDevWorkspaceButton.isEnabled = isStopped(workspace) + stopDevWorkspaceButton.isEnabled = isRunning(workspace) - refreshNextButton() - - if (isAlreadyConnected(workspace)) { - stopDevWorkspaceButton.toolTipText = "This workspace is already connected." - } else { - stopDevWorkspaceButton.toolTipText = null - } - } - - ) - } - - private fun getSelectedWorkspace(): DevWorkspace? { - val selectedIndex = listDevWorkspaces.minSelectionIndex - return if (selectedIndex >= 0 - && selectedIndex < listDevWorkspaces.itemsCount) { - listDevWorkspaces.model.getElementAt(selectedIndex) - } else { - null + refreshNextButton() } } + private fun getSelectedWorkspaceListItem(): DevWorkspaceListItem? = devWorkspacesTable.selectedItem + + private fun getSelectedWorkspace(): DevWorkspace? = getSelectedWorkspaceListItem()?.workspace + override fun isNextEnabled(): Boolean { - val workspace = getSelectedWorkspace() ?: return false - // Do not gate on "already connected": in IDEA the wizard often stays open after - // Guest close, and thin-client / activeWorkspaces tracking is too unreliable to - // keep Connect disabled. Tooltip still reflects isAlreadyConnected. - return isRunning(workspace) + val item = getSelectedWorkspaceListItem() ?: return false + return item.editor.kind.isConnectableEditor() && isRunning(item.workspace) } private fun isStopped(workspace: DevWorkspace?): Boolean { @@ -455,64 +463,13 @@ class DevSpacesWorkspacesStepView( return workspace?.running ?: false } - /** - * Returns true if the workspace is already connected (client-side session active). - */ - private fun isAlreadyConnected(workspace: DevWorkspace?): Boolean { - if (workspace == null) return false - return devSpacesContext.isWorkspaceActive(workspace) - } - - class DevWorkspaceListRenderer : ColoredListCellRenderer() { - override fun customizeCellRenderer( - list: JList, - devWorkspace: DevWorkspace, - index: Int, - selected: Boolean, - hasFocus: Boolean - ) { - val icon = DevSpacesIcons.getWorkspacePhaseIcon(devWorkspace.phase) ?: AllIcons.Empty - setIcon(icon) - - border = JBUI.Borders.emptyLeft(6) - font = JBFont.h4().asPlain() - - append(devWorkspace.displayName, SimpleTextAttributes.REGULAR_ATTRIBUTES) - if (hasMultipleWorkspaces(list.model)) { - val fm = getFontMetrics(font) - val maxNameWidth = calculateMaxNameWidth(list.model, fm) - val padding = maxNameWidth - fm.stringWidth(devWorkspace.name) + 20 // extra gap - append(" ".repeat(padding / fm.stringWidth(" ")), SimpleTextAttributes.REGULAR_ATTRIBUTES) - append(" @${devWorkspace.namespace}", SimpleTextAttributes.GRAYED_ITALIC_ATTRIBUTES) - } - } - - private fun calculateMaxNameWidth(listModel: ListModel, fm: FontMetrics): Int { - var maxWidth = 0 - for (i in 0 until listModel.size) { - val nameWidth = fm.stringWidth(listModel.getElementAt(i).name) - if (nameWidth > maxWidth) maxWidth = nameWidth - } - return maxWidth - } - - private fun hasMultipleWorkspaces(listModel: ListModel): Boolean { - if (listModel.size <= 1) return false - - val firstNamespace = listModel.getElementAt(0).namespace - return (1 until listModel.size) - .asSequence() - .map { listModel.getElementAt(it).namespace } - .any { it != firstNamespace } - } - } - fun refreshNextButton() { enableNextButton?.invoke() } override fun dispose() { - watchManager?.stop() + watchManager?.dispose() + watchManager = null } inner class DevWorkspaceSelection : ListSelectionListener { @@ -521,83 +478,4 @@ class DevSpacesWorkspacesStepView( refreshNextButton() } } - - private class WorkspacesWatch( - private val client: ApiClient, - private val workspacesDataModel: DefaultListModel - ) { - private val devWorkspaces = DevWorkspaces(client) - private val watchManager = DevWorkspaceWatchManager( - createWatcher = { ns, latestResourceVersion -> - devWorkspaces.createWatcher(ns, latestResourceVersion = latestResourceVersion) - }, - createFilter = { ns -> - devWorkspaces.createIdeaEditorFilter(ns) - }, - listener = object : DevWorkspaceListener { - override fun onAdded(dw: DevWorkspace) { - onUpdated(dw) - } - - override fun onUpdated(dw: DevWorkspace) { - invokeLater( - ModalityState.any(), - { - val idx = indexOfFirst { it.name == dw.name && it.namespace == dw.namespace } - if (idx == -1) { - val index = findInsertIndex(dw) - workspacesDataModel.add(index, dw) - } else { - workspacesDataModel.set(idx, dw) - } - } - ) - } - - override fun onDeleted(dw: DevWorkspace) { - invokeLater( - ModalityState.any(), - { - val idx = indexOfFirst { it.namespace == dw.namespace && it.name == dw.name } - if (idx >= 0) { - workspacesDataModel.remove(idx) - } - } - ) - } - - private fun findInsertIndex(dw: DevWorkspace): Int { - val n = workspacesDataModel.size - val groupStart = (0 until n).firstOrNull { - workspacesDataModel[it].namespace >= dw.namespace - } ?: n - - val insertIndex = (groupStart until n).firstOrNull { - workspacesDataModel[it].namespace == dw.namespace && workspacesDataModel[it].name >= dw.name - } ?: run { - var endOfGroup = groupStart - while (endOfGroup < n && workspacesDataModel[endOfGroup].namespace == dw.namespace) endOfGroup++ - endOfGroup - } - - return insertIndex - } - } - ) - - private fun indexOfFirst(predicate: (DevWorkspace) -> Boolean): Int { - for (i in 0 until workspacesDataModel.size()) { - if (predicate(workspacesDataModel.get(i))) return i - } - return -1 - } - - fun start(lastResourceVersions: Map = emptyMap()) { - watchManager.start(lastResourceVersions) - } - - fun stop() { - watchManager.stop() - } - } } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableCellRenderers.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableCellRenderers.kt new file mode 100644 index 00000000..b7a23bc6 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableCellRenderers.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.intellij.icons.AllIcons +import com.intellij.util.IconUtil +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.redhat.devtools.gateway.DevSpacesIcons +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import java.awt.Component +import javax.swing.JLabel +import javax.swing.JTable +import javax.swing.table.DefaultTableCellRenderer + +internal const val ICON_SIZE = 16 + +private const val TEXT_CELL_LEFT_PADDING = 8 + +internal abstract class CenteredIconCellRenderer : DefaultTableCellRenderer() { + protected abstract fun iconFor(item: DevWorkspaceListItem): javax.swing.Icon + + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int + ): Component { + val item = value as DevWorkspaceListItem + val icon = iconFor(item) + val label = super.getTableCellRendererComponent( + table, + null, + isSelected, + false, + row, + column + ) as JLabel + label.icon = IconUtil.downscaleIconToSize(icon, JBUI.scale(ICON_SIZE), JBUI.scale(ICON_SIZE)) + label.horizontalAlignment = JLabel.CENTER + label.border = JBUI.Borders.empty() + return label + } +} + +internal class StatusCellRenderer : CenteredIconCellRenderer() { + override fun iconFor(item: DevWorkspaceListItem) = + DevSpacesIcons.getWorkspacePhaseIcon(item.workspace.phase) ?: AllIcons.Empty +} + +internal class EditorCellRenderer : CenteredIconCellRenderer() { + override fun iconFor(item: DevWorkspaceListItem) = + DevSpacesIcons.getEditorIcon(item.editor.kind) +} + +internal class TextCellRenderer( + private val textProvider: (DevWorkspaceListItem) -> String +) : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int + ): Component { + val item = value as DevWorkspaceListItem + val label = super.getTableCellRendererComponent( + table, + textProvider(item), + isSelected, + false, + row, + column + ) as JLabel + label.font = JBFont.h4().asPlain() + label.border = JBUI.Borders.emptyLeft(TEXT_CELL_LEFT_PADDING) + return label + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModel.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModel.kt new file mode 100644 index 00000000..70730dcf --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModel.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind +import javax.swing.table.AbstractTableModel + +internal const val STATUS_COLUMN = 0 +internal const val NAME_COLUMN = 1 +internal const val EDITOR_COLUMN = 2 +internal const val PROJECT_COLUMN = 3 + +private fun editorPriority(kind: WorkspaceEditorKind): Int { + return when (kind) { + WorkspaceEditorKind.INTELLIJ_IDEA, WorkspaceEditorKind.PYCHARM, WorkspaceEditorKind.CLION, + WorkspaceEditorKind.GOLAND, WorkspaceEditorKind.PHPSTORM, WorkspaceEditorKind.RIDER, + WorkspaceEditorKind.RUBYMINE, WorkspaceEditorKind.WEBSTORM, WorkspaceEditorKind.HERDR, + WorkspaceEditorKind.KIRO, WorkspaceEditorKind.JETBRAINS -> 0 + WorkspaceEditorKind.UNKNOWN -> 1 + else -> 2 + } +} + +private val DEV_WORKSPACE_COMPARATOR = Comparator { a, b -> + val byEditor = editorPriority(a.editor.kind).compareTo(editorPriority(b.editor.kind)) + if (byEditor != 0) return@Comparator byEditor + val byNamespace = a.workspace.namespace.compareTo(b.workspace.namespace) + if (byNamespace != 0) return@Comparator byNamespace + a.workspace.name.compareTo(b.workspace.name) +} + +internal class DevWorkspaceTableModel : AbstractTableModel() { + private val items = ArrayList() + + override fun getRowCount(): Int = items.size + + override fun getColumnCount(): Int = 4 + + override fun getColumnName(column: Int): String { + return when (column) { + STATUS_COLUMN -> DevSpacesBundle.message("connector.wizard_step.remote_server_connection.column.status") + NAME_COLUMN -> DevSpacesBundle.message("connector.wizard_step.remote_server_connection.column.name") + EDITOR_COLUMN -> DevSpacesBundle.message("connector.wizard_step.remote_server_connection.column.editor") + PROJECT_COLUMN -> DevSpacesBundle.message("connector.wizard_step.remote_server_connection.column.project") + else -> "" + } + } + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any = items[rowIndex] + + operator fun get(index: Int): DevWorkspaceListItem = items[index] + + fun indexOfFirst(predicate: (DevWorkspaceListItem) -> Boolean): Int { + for (i in items.indices) { + if (predicate(items[i])) return i + } + return -1 + } + + fun clear() { + val size = items.size + items.clear() + if (size > 0) fireTableRowsDeleted(0, size - 1) + } + + fun addAll(newItems: List) { + val start = items.size + items.addAll(newItems.sortedWith(DEV_WORKSPACE_COMPARATOR)) + if (newItems.isNotEmpty()) fireTableRowsInserted(start, items.size - 1) + } + + fun addSorted(item: DevWorkspaceListItem) { + var low = 0 + var high = items.size + while (low < high) { + val mid = (low + high) ushr 1 + if (DEV_WORKSPACE_COMPARATOR.compare(item, items[mid]) < 0) { + high = mid + } else { + low = mid + 1 + } + } + add(low, item) + } + + fun add(index: Int, item: DevWorkspaceListItem) { + items.add(index, item) + fireTableRowsInserted(index, index) + } + + fun set(index: Int, item: DevWorkspaceListItem) { + val old = items[index] + if (DEV_WORKSPACE_COMPARATOR.compare(old, item) != 0) { + // Sort key changed (e.g. editor resolved from Unknown) — reposition the row. + items.removeAt(index) + fireTableRowsDeleted(index, index) + addSorted(item) + } else { + items[index] = item + fireTableRowsUpdated(index, index) + } + } + + fun remove(index: Int) { + items.removeAt(index) + fireTableRowsDeleted(index, index) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdater.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdater.kt new file mode 100644 index 00000000..3a8370ff --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdater.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.redhat.devtools.gateway.devworkspace.DevWorkspace +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListener +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorInfo +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorResolver + +/** + * Applies DevWorkspace watch events to the [DevWorkspaceTableModel], resolving editor info + * via [WorkspaceEditorResolver] and keeping the model sorted. + * + * Note: listener callbacks are already invoked on the EDT by [DevWorkspaceWatchManager], + * so no additional EDT dispatch is done here. + */ +internal class DevWorkspaceTableUpdater( + private val workspacesDataModel: DevWorkspaceTableModel, + private val editorResolver: WorkspaceEditorResolver, +) : DevWorkspaceListener { + + override fun onAdded(dw: DevWorkspace) { + val resolved = editorResolver.resolve(dw) + insertOrUpdate(dw, resolved) + // Namespace negatively cached — no fetch needed; Unknown stays visible. + if (resolved.kind == WorkspaceEditorKind.UNKNOWN && !editorResolver.templatesUnavailable(dw.namespace)) { + editorResolver.backgroundFetchTemplatesAndPatch(dw) + } + } + + override fun onUpdated(dw: DevWorkspace) { + val idx = workspacesDataModel.indexOfFirst { it.workspace == dw } + if (idx != -1) { + editorResolver.refreshTracked(dw) + // Phase/status updates do not change the editor. Keep the previously + // resolved editor info so template-based JetBrains does not flip (CRW-11897). + val item = DevWorkspaceListItem(dw, workspacesDataModel[idx].editor) + workspacesDataModel.set(idx, item) + } else { + // Missed ADDED (reconnect gap) — resolve like a new workspace. + onAdded(dw) + } + } + + override fun onDeleted(dw: DevWorkspace) { + val idx = workspacesDataModel.indexOfFirst { it.workspace == dw } + if (idx >= 0) { + workspacesDataModel.remove(idx) + } + editorResolver.untrack(dw) + } + + private fun insertOrUpdate(dw: DevWorkspace, editor: WorkspaceEditorInfo) { + val idx = workspacesDataModel.indexOfFirst { it.workspace == dw } + val item = DevWorkspaceListItem(dw, editor) + if (idx == -1) { + workspacesDataModel.addSorted(item) + } else { + workspacesDataModel.set(idx, item) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspacesTable.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspacesTable.kt new file mode 100644 index 00000000..a1099383 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspacesTable.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import com.redhat.devtools.gateway.openshift.Utils +import java.awt.Component +import java.awt.event.MouseEvent +import javax.swing.JTable +import javax.swing.ListSelectionModel + +private const val ICON_COLUMN_PADDING = 12 +private const val NAME_COLUMN_PADDING = 20 +private const val PROJECT_COLUMN_PADDING = 40 + +internal class DevWorkspacesTable( + val devWorkspaceModel: DevWorkspaceTableModel = DevWorkspaceTableModel() +) : JBTable(devWorkspaceModel) { + + init { + autoResizeMode = JTable.AUTO_RESIZE_ALL_COLUMNS + tableHeader.reorderingAllowed = false + setShowVerticalLines(false) + setShowHorizontalLines(false) + setCellSelectionEnabled(false) + setRowSelectionAllowed(true) + setColumnSelectionAllowed(false) + rowHeight = JBUI.scale(28) + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + emptyText.text = DevSpacesBundle.message("connector.wizard_step.remote_server_connection.list.empty_text") + configureColumns() + } + + override fun getToolTipText(event: MouseEvent): String? { + val row = rowAtPoint(event.point) + val column = columnAtPoint(event.point) + if (row < 0 || column < 0) return null + val item = devWorkspaceModel[row] + return when (column) { + STATUS_COLUMN -> item.workspace.phase + EDITOR_COLUMN -> item.editor.tooltip + else -> null + } + } + + val selectedItem: DevWorkspaceListItem? + get() { + val row = selectedRow + return if (row in 0 until devWorkspaceModel.rowCount) devWorkspaceModel[row] else null + } + + fun updateColumnWidths() { + val fm = getFontMetrics(JBFont.h4().asPlain()) + var maxNameWidth = 0 + var maxProjectWidth = 0 + for (i in 0 until devWorkspaceModel.rowCount) { + val item = devWorkspaceModel[i] + maxNameWidth = maxOf(maxNameWidth, fm.stringWidth(item.workspace.displayName)) + maxProjectWidth = maxOf(maxProjectWidth, fm.stringWidth(item.workspace.namespace)) + } + columnModel.getColumn(NAME_COLUMN).preferredWidth = + JBUI.scale(maxNameWidth + NAME_COLUMN_PADDING) + columnModel.getColumn(PROJECT_COLUMN).preferredWidth = + JBUI.scale(maxProjectWidth + PROJECT_COLUMN_PADDING) + } + + private fun configureColumns() { + columnModel.getColumn(STATUS_COLUMN).cellRenderer = StatusCellRenderer() + columnModel.getColumn(NAME_COLUMN).cellRenderer = + TextCellRenderer { it.workspace.displayName } + columnModel.getColumn(EDITOR_COLUMN).cellRenderer = EditorCellRenderer() + columnModel.getColumn(PROJECT_COLUMN).cellRenderer = + TextCellRenderer { it.workspace.namespace } + + val iconColumnWidth = JBUI.scale(ICON_SIZE) + JBUI.scale(ICON_COLUMN_PADDING) + configureIconColumn(STATUS_COLUMN, iconColumnWidth) + configureIconColumn(EDITOR_COLUMN, iconColumnWidth) + } + + private fun configureIconColumn(columnIndex: Int, iconColumnWidth: Int) { + val column = columnModel.getColumn(columnIndex) + val headerRenderer = column.headerRenderer ?: tableHeader.defaultRenderer + val headerComponent = headerRenderer.getTableCellRendererComponent( + this, + devWorkspaceModel.getColumnName(columnIndex), + false, + false, + -1, + columnIndex + ) as Component + val width = maxOf(iconColumnWidth, headerComponent.preferredSize.width) + column.apply { + preferredWidth = width + minWidth = width + maxWidth = width + } + } +} + +private val com.redhat.devtools.gateway.devworkspace.DevWorkspace.displayName: String + get() { + val label = Utils.getValue(this.labels, arrayOf("kubernetes.io/metadata.name")) as String? + return label + ?: this.name + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/WorkspacesWatch.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/WorkspacesWatch.kt new file mode 100644 index 00000000..7ad99ea9 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/WorkspacesWatch.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeLater +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceTemplate +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceWatchManager +import com.redhat.devtools.gateway.devworkspace.DevWorkspaces +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorResolver +import io.kubernetes.client.openapi.ApiClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +/** + * Keeps the [DevWorkspaceTableModel] in sync with the cluster via [DevWorkspaceWatchManager], + * delegating editor resolution to [WorkspaceEditorResolver] and row updates to [DevWorkspaceTableUpdater]. + */ +internal class WorkspacesWatch( + client: ApiClient, + private val workspacesTableModel: DevWorkspaceTableModel, + private val dispatchEdt: (() -> Unit) -> Unit = { block -> + invokeLater(ModalityState.any(), block) + } +) { + private val devWorkspaces = DevWorkspaces(client) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val editorResolver = WorkspaceEditorResolver( + devWorkspaces = devWorkspaces, + scope = scope, + onEditorResolved = { patches -> + dispatchEdt { + for ((dw, editor) in patches) { + val idx = workspacesTableModel.indexOfFirst { it.workspace == dw } + if (idx != -1) { + val current = workspacesTableModel[idx] + workspacesTableModel.set(idx, DevWorkspaceListItem(current.workspace, editor)) + } + } + } + } + ) + + private val watchManager = DevWorkspaceWatchManager( + createWatcher = { ns, latestResourceVersion -> + devWorkspaces.createWatcher(ns, latestResourceVersion = latestResourceVersion) + }, + createFilter = { _ -> + { true } + }, + listener = DevWorkspaceTableUpdater(workspacesTableModel, editorResolver) + ) + + fun seedTemplateCache( + mapsByNamespace: Map>>, + unavailableNamespaces: Set + ) { + editorResolver.seedTemplateCache(mapsByNamespace, unavailableNamespaces) + } + + fun start(lastResourceVersions: Map = emptyMap()) { + watchManager.start(lastResourceVersions) + } + + fun stop() { + watchManager.stop() + // Cancel in-flight template fetches for this watch cycle; keep scope for restart. + editorResolver.cancelInFlight() + } + + fun dispose() { + stop() + scope.cancel() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/Dialogs.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/Dialogs.kt index 2b757dd9..2e10c1df 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/Dialogs.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/Dialogs.kt @@ -3,6 +3,7 @@ package com.redhat.devtools.gateway.view.ui import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.ui.Messages +import com.redhat.devtools.gateway.DevSpacesBundle import java.util.concurrent.atomic.AtomicInteger import javax.swing.Icon @@ -95,6 +96,27 @@ object Dialogs { ) == confirmOptionIndex } + /** + * Shows a warning dialog when connecting to a workspace whose editor is unknown. + * Returns `true` if the user click "Connect", `false` otherwise. + * + * @return true if the user wants to continue connecting, false to cancel + */ + fun confirmUnknownEditor(modalityState: ModalityState? = ModalityState.any()): Boolean { + return confirm( + message = DevSpacesBundle.message( + "connector.wizard_step.remote_server_connection.dialog.unknown_editor.message" + ), + title = DevSpacesBundle.message( + "connector.wizard_step.remote_server_connection.dialog.unknown_editor.title" + ), + buttons = arrayOf("Cancel", "Connect"), + confirmOptionIndex = 1, + defaultOptionIndex = 0, + modalityState = modalityState + ) + } + /** * Shows a dialog for when the workspace IDE is not responding. * diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt index 9d9ad73e..a9f2a3b7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt @@ -19,6 +19,7 @@ import java.awt.event.MouseEvent import javax.swing.JComboBox import javax.swing.JList import javax.swing.JPanel +import javax.swing.JTable import javax.swing.event.AncestorEvent fun JList.onDoubleClick(action: (T) -> Unit) { @@ -30,6 +31,18 @@ fun JList.onDoubleClick(action: (T) -> Unit) { }.installOn(this) } +fun JTable.onDoubleClick(action: (Int) -> Unit) { + object : DoubleClickListener() { + override fun onDoubleClick(e: MouseEvent): Boolean { + val row = rowAtPoint(e.point) + if (row !in 0..) { addAncestorListener(object : AncestorListenerAdapter() { override fun ancestorAdded(event: AncestorEvent?) { diff --git a/src/main/resources/icons/editors/chemuxer.svg b/src/main/resources/icons/editors/chemuxer.svg new file mode 100644 index 00000000..023b3920 --- /dev/null +++ b/src/main/resources/icons/editors/chemuxer.svg @@ -0,0 +1,8 @@ + + + c + h + + + + diff --git a/src/main/resources/icons/editors/clion.svg b/src/main/resources/icons/editors/clion.svg new file mode 100644 index 00000000..5f985807 --- /dev/null +++ b/src/main/resources/icons/editors/clion.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/goland.svg b/src/main/resources/icons/editors/goland.svg new file mode 100644 index 00000000..8f425dcb --- /dev/null +++ b/src/main/resources/icons/editors/goland.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/herdr.svg b/src/main/resources/icons/editors/herdr.svg new file mode 100644 index 00000000..23057315 --- /dev/null +++ b/src/main/resources/icons/editors/herdr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/editors/intellij-idea.svg b/src/main/resources/icons/editors/intellij-idea.svg new file mode 100644 index 00000000..ad112204 --- /dev/null +++ b/src/main/resources/icons/editors/intellij-idea.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/jetbrains.svg b/src/main/resources/icons/editors/jetbrains.svg new file mode 100644 index 00000000..bf2a1ec3 --- /dev/null +++ b/src/main/resources/icons/editors/jetbrains.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/icons/editors/kiro.svg b/src/main/resources/icons/editors/kiro.svg new file mode 100644 index 00000000..b0529503 --- /dev/null +++ b/src/main/resources/icons/editors/kiro.svg @@ -0,0 +1 @@ +Kiro diff --git a/src/main/resources/icons/editors/phpstorm.svg b/src/main/resources/icons/editors/phpstorm.svg new file mode 100644 index 00000000..d5780cfa --- /dev/null +++ b/src/main/resources/icons/editors/phpstorm.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/pycharm.svg b/src/main/resources/icons/editors/pycharm.svg new file mode 100644 index 00000000..30948757 --- /dev/null +++ b/src/main/resources/icons/editors/pycharm.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/rider.svg b/src/main/resources/icons/editors/rider.svg new file mode 100644 index 00000000..2865be7a --- /dev/null +++ b/src/main/resources/icons/editors/rider.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/rubymine.svg b/src/main/resources/icons/editors/rubymine.svg new file mode 100644 index 00000000..ce84c059 --- /dev/null +++ b/src/main/resources/icons/editors/rubymine.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/unknown.svg b/src/main/resources/icons/editors/unknown.svg new file mode 100644 index 00000000..586bdfee --- /dev/null +++ b/src/main/resources/icons/editors/unknown.svg @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/editors/vscode.svg b/src/main/resources/icons/editors/vscode.svg new file mode 100644 index 00000000..c453e633 --- /dev/null +++ b/src/main/resources/icons/editors/vscode.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/editors/web-terminal.svg b/src/main/resources/icons/editors/web-terminal.svg new file mode 100644 index 00000000..950b68f3 --- /dev/null +++ b/src/main/resources/icons/editors/web-terminal.svg @@ -0,0 +1,11 @@ + + + $ + + $ + + $ + + + + diff --git a/src/main/resources/icons/editors/webstorm.svg b/src/main/resources/icons/editors/webstorm.svg new file mode 100644 index 00000000..9cdb923e --- /dev/null +++ b/src/main/resources/icons/editors/webstorm.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 6c9955d5..d86cf5e5 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -40,6 +40,12 @@ connector.wizard_step.remote_server_connection.button.start=Start connector.wizard_step.remote_server_connection.button.stop=Stop connector.wizard_step.remote_server_connection.button.refresh=Refresh connector.wizard_step.remote_server_connection.list.empty_text=There are no DevWorkspaces +connector.wizard_step.remote_server_connection.column.status=Status +connector.wizard_step.remote_server_connection.column.name=Name +connector.wizard_step.remote_server_connection.column.editor=Editor +connector.wizard_step.remote_server_connection.column.project=Project +connector.wizard_step.remote_server_connection.dialog.unknown_editor.title=Unknown Editor +connector.wizard_step.remote_server_connection.dialog.unknown_editor.message=The editor of this workspace is unknown, so connecting to it may fail.\nDo you want to continue? connector.loader.devspaces.fetching.text=Fetching DevWorkspaces... connector.loader.devspaces.connecting.text=Connecting to the Remote IDE... diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacePatchTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacePatchTest.kt index 5432bf5c..c5298f93 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacePatchTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacePatchTest.kt @@ -328,23 +328,6 @@ class DevWorkspacePatchTest { .hasMessageContaining("Patch failed") } - private fun createDevWorkspace( - name: String = workspaceName, - namespace: String = this.namespace, - annotations: Map = emptyMap() - ): DevWorkspace { - val metadata = DevWorkspaceObjectMeta( - name = name, - namespace = namespace, - uid = "test-uid", - annotations = annotations, - labels = emptyMap() - ) - val spec = DevWorkspaceSpec(started = true) - val status = DevWorkspaceStatus(phase = "Running") - return DevWorkspace(metadata, spec, status) - } - @Test fun `#hasRestartAnnotation returns true when annotation is present and set to true`() { // given diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt index 787133e4..885ceb7a 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt @@ -11,10 +11,12 @@ */ package com.redhat.devtools.gateway.devworkspace +import com.intellij.openapi.diagnostic.Logger import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi import io.mockk.* +import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -200,39 +202,289 @@ class DevWorkspacesTest { } @Test - fun `#isIdeaEditorBased returns true for full path editor annotation`() { - val dw = createDevWorkspaceWithEditor("eclipse/che-idea-server/latest") - assert(devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#list includes non-Idea workspaces`() { + // given + mockListDevWorkspaces( + listOf( + createDevWorkspaceItem("idea-workspace", "eclipse/che-idea-server/latest"), + createDevWorkspaceItem("code-workspace", "eclipse/che-code/latest") + ) + ) + mockListDevWorkspaceTemplates(emptyList()) + + // when + val workspaces = devWorkspaces.list(namespace) + + // then + assertThat(workspaces).hasSize(2) + assertThat(workspaces.map { it.name }) + .containsExactlyInAnyOrder("idea-workspace", "code-workspace") + } + + @Test + fun `#listWithResult resolves labels for Idea and non-Idea workspaces`() { + // given + mockListDevWorkspaces( + listOf( + createDevWorkspaceItem("idea-workspace", "eclipse/che-idea-server/latest"), + createDevWorkspaceItem("code-workspace", "eclipse/che-code/latest") + ) + ) + mockListDevWorkspaceTemplates(emptyList()) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(2) + assertThat(result.items.first { it.workspace.name == "idea-workspace" }.editor.kind) + .isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + assertThat(result.items.first { it.workspace.name == "idea-workspace" }.editor.tooltip) + .isEqualTo("IntelliJ IDEA Ultimate (desktop)") + assertThat(result.items.first { it.workspace.name == "code-workspace" }.editor.kind) + .isEqualTo(WorkspaceEditorKind.VSCODE) + assertThat(result.items.first { it.workspace.name == "code-workspace" }.editor.tooltip) + .isEqualTo("VS Code - Open Source") + } + + @Test + fun `#listWithResult treats unauthorized templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(401, "Unauthorized")) } @Test - fun `#isIdeaEditorBased returns true for editor name only`() { - val dw = createDevWorkspaceWithEditor("che-idea-server") - assert(devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#listWithResult treats forbidden templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(403, "Forbidden")) } @Test - fun `#isIdeaEditorBased returns true for editor name with version`() { - val dw = createDevWorkspaceWithEditor("che-idea-server/latest") - assert(devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#listWithResult treats not-found templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(404, "Not Found")) } @Test - fun `#isIdeaEditorBased returns true for editor name with prefix`() { - val dw = createDevWorkspaceWithEditor("eclipse/che-idea-server") - assert(devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#listWithResult returns empty items on devworkspaces list 403`() { + // given + mockListDevWorkspacesThrows(ApiException(403, "Forbidden")) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).isEmpty() + assertThat(result.templatesUnavailable).isFalse() + } + + @Test + fun `#listWithResult returns empty items on devworkspaces list 404`() { + // given — plain 404 without CRD-missing body (namespace has no DevSpaces) + mockListDevWorkspacesThrows(ApiException(404, "Not Found")) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).isEmpty() + assertThat(result.templatesUnavailable).isFalse() + } + + @Test + fun `#listWithResult returns empty items on devworkspaces list 404 CRD missing`() { + // given — response body triggers isDevWorkspaceCrdMissing() + mockListDevWorkspacesThrows( + apiException( + 404, + """{"message":"could not find the requested resource: devworkspaces","status":"Failure","details":{"kind":"devworkspaces"}}""", + ) + ) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).isEmpty() + assertThat(result.templatesUnavailable).isFalse() + } + + @Test + fun `#listWithResult throws ApiException on devworkspaces list 401`() { + mockkStatic(Logger::class) + every { Logger.getInstance(DevWorkspaces::class.java) } returns mockk(relaxed = true) + try { + // given + mockListDevWorkspacesThrows(ApiException(401, "Unauthorized")) + + // when/then + assertThatThrownBy { + devWorkspaces.listWithResult(namespace) + }.isInstanceOf(ApiException::class.java) + .hasMessageContaining("Unauthorized") + } finally { + unmockkStatic(Logger::class) + } + } + + @Test + fun `#listWithResult resolves template-based JetBrains workspace`() { + // given — no che-editor annotation, but a template with an idea-server volume + mockListDevWorkspaces(listOf(createDevWorkspaceItem("template-workspace", null))) + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(1) + assertThat(result.items[0].editor.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + assertThat(result.items[0].editor.tooltip).isEqualTo("JetBrains") + } + + @Test + fun `#listWithResult includes templates in result`() { + // given + mockListDevWorkspaces(listOf(createDevWorkspaceItem("template-workspace", null))) + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.templates).isNotEmpty + assertThat(result.templates).containsKey("test-uid") + assertThat(result.templates["test-uid"]).hasSize(1) + assertThat(result.templatesUnavailable).isFalse() + } + + @Test + fun `#loadTemplates returns unavailable true for 401`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(401, "Unauthorized")) + + // when + val load = devWorkspaces.loadTemplates(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() + } + + @Test + fun `#loadTemplates returns unavailable true for 403`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(403, "Forbidden")) + + // when + val load = devWorkspaces.loadTemplates(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() } @Test - fun `#isIdeaEditorBased returns false for non-idea editor`() { - val dw = createDevWorkspaceWithEditor("eclipse/che-code/latest") - assert(!devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#loadTemplates returns unavailable true for 404`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(404, "Not Found")) + + // when + val load = devWorkspaces.loadTemplates(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() } @Test - fun `#isIdeaEditorBased returns false for unknown editor`() { - val dw = createDevWorkspaceWithEditor("unknown") - assert(!devWorkspaces.isIdeaEditorBased(dw, emptyMap())) + fun `#loadTemplates returns available with map on success`() { + // given + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val load = devWorkspaces.loadTemplates(namespace) + + // then + assertThat(load.unavailable).isFalse() + assertThat(load.map).isNotEmpty + assertThat(load.map).containsKey("test-uid") + } + + private fun listWithResultTemplatesIgnored(exception: ApiException) { + // given + mockListDevWorkspaces(listOf(createDevWorkspaceItem("plain-workspace", null))) + mockListDevWorkspaceTemplatesThrows(exception) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(1) + assertThat(result.items[0].editor.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(result.items[0].editor.tooltip).isEqualTo("Unknown Editor") + assertThat(result.templates).isEmpty() + assertThat(result.templatesUnavailable).isTrue() } // Helper methods @@ -264,6 +516,87 @@ class DevWorkspacesTest { } } + private fun mockListDevWorkspaces(items: List>) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspaces" + ) + } returns mockk { + every { execute() } returns mapOf( + "metadata" to mapOf("resourceVersion" to "1"), + "items" to items + ) + } + } + + private fun apiException(code: Int, body: String): ApiException = + ApiException(code, "error", emptyMap(), body) + + private fun mockListDevWorkspacesThrows(exception: ApiException) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspaces" + ) + } returns mockk { + every { execute() } throws exception + } + } + + private fun mockListDevWorkspaceTemplates(items: List>) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspacetemplates" + ) + } returns mockk { + every { execute() } returns mapOf("items" to items) + } + } + + private fun mockListDevWorkspaceTemplatesThrows(exception: ApiException) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspacetemplates" + ) + } returns mockk { + every { execute() } throws exception + } + } + + private fun createDevWorkspaceItem(name: String, cheEditor: String?): Map { + val annotations = if (cheEditor != null) { + mapOf("che.eclipse.org/che-editor" to cheEditor) + } else { + emptyMap() + } + return mapOf( + "metadata" to mapOf( + "name" to name, + "namespace" to namespace, + "uid" to "test-uid", + "annotations" to annotations, + "labels" to mapOf("kubernetes.io/metadata.name" to name) + ), + "spec" to mapOf( + "started" to true + ), + "status" to mapOf( + "phase" to "Running" + ) + ) + } + private fun mockPatchDevWorkspace(callBuilder: okhttp3.Call) { every { anyConstructed().patchNamespacedCustomObject( @@ -307,20 +640,6 @@ class DevWorkspacesTest { } } - private fun createDevWorkspaceWithEditor(cheEditor: String): DevWorkspace { - return DevWorkspace( - DevWorkspaceObjectMeta( - name = "test-workspace", - namespace = "test-namespace", - uid = "test-uid", - annotations = mapOf("che.eclipse.org/che-editor" to cheEditor), - labels = emptyMap() - ), - DevWorkspaceSpec(started = true), - DevWorkspaceStatus(phase = "Running") - ) - } - private fun createMockDevWorkspace( namespace: String, name: String, diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt new file mode 100644 index 00000000..49e093d2 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.devworkspace + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class WorkspaceEditorInfoProviderTest { + + @Test + fun `#isJetBrainsEditor returns true for template with idea-server volume`() { + val dw = DevWorkspace( + DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val templateMap = mapOf( + "uid1" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "test", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("uid1")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + assertThat(WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap)).isTrue() + } + + @Test + fun `#isJetBrainsEditor returns false for template without idea-server volume`() { + val dw = DevWorkspace( + DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val templateMap = mapOf( + "uid1" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "test", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("uid1")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "vscode-server")))) + ) + ) + ) + assertThat(WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap)).isFalse() + } + + @Test + fun `#create resolves template-based JetBrains editor to JETBRAINS`() { + val dw = DevWorkspace( + DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val templateMap = mapOf( + "uid1" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "test", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("uid1")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + val info = WorkspaceEditorInfoProvider.create(dw, templateMap) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + assertThat(info.tooltip).isEqualTo("JetBrains") + } + + @Test + fun `#create maps webstorm editor to WEBSTORM`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-webstorm-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.WEBSTORM) + assertThat(info.tooltip).isEqualTo("JetBrains WebStorm (desktop)") + } + + @Test + fun `#create maps pycharm editor to PYCHARM`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-pycharm-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.PYCHARM) + assertThat(info.tooltip).isEqualTo("PyCharm") + } + + @Test + fun `#create maps unknown che-server editor to JETBRAINS`() { + val dw = createDevWorkspaceWithEditor("che-foo-server") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + assertThat(info.tooltip).isEqualTo("JetBrains") + } + + @Test + fun `#create maps vscode editor to VSCODE`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-code/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.VSCODE) + assertThat(info.tooltip).isEqualTo("VS Code - Open Source") + } + + @Test + fun `#create maps intellij editor to INTELLIJ_IDEA`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-idea-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + assertThat(info.tooltip).isEqualTo("IntelliJ IDEA Ultimate (desktop)") + } + + @Test + fun `#create maps clion editor to CLION`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-clion-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.CLION) + assertThat(info.tooltip).isEqualTo("JetBrains CLion (desktop)") + } + + @Test + fun `#create maps goland editor to GOLAND`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-goland-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.GOLAND) + assertThat(info.tooltip).isEqualTo("JetBrains GoLand (desktop)") + } + + @Test + fun `#create maps phpstorm editor to PHPSTORM`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-phpstorm-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.PHPSTORM) + assertThat(info.tooltip).isEqualTo("JetBrains PhpStorm (desktop)") + } + + @Test + fun `#create maps rider editor to RIDER`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-rider-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.RIDER) + assertThat(info.tooltip).isEqualTo("JetBrains Rider (desktop)") + } + + @Test + fun `#create maps rubymine editor to RUBYMINE`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-rubymine-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.RUBYMINE) + assertThat(info.tooltip).isEqualTo("JetBrains RubyMine (desktop)") + } + + @Test + fun `#create maps chemuxer editor to CHEMUXER`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-chemuxer-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.CHEMUXER) + assertThat(info.tooltip).isEqualTo("Chemuxer") + } + + @Test + fun `#create maps herdr editor to HERDR`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-herdr-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.HERDR) + assertThat(info.tooltip).isEqualTo("Herdr") + } + + @Test + fun `#create maps kiro editor to KIRO`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-kiro-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.KIRO) + assertThat(info.tooltip).isEqualTo("Kiro (desktop)") + } + + @Test + fun `#create maps web terminal editor to WEB_TERMINAL`() { + val dw = createDevWorkspaceWithEditor("eclipse/che-web-terminal-server/latest") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.WEB_TERMINAL) + assertThat(info.tooltip).isEqualTo("Web Terminal") + } + + @Test + fun `#create returns UNKNOWN for empty annotation`() { + val dw = DevWorkspace( + DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(info.tooltip).isEqualTo("Unknown Editor") + } + + @Test + fun `#create returns UNKNOWN with fallback segment for partial path`() { + val dw = createDevWorkspaceWithEditor("some/partial") + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(info.tooltip).isEqualTo("partial") + } + + private fun createDevWorkspaceWithEditor(cheEditor: String): DevWorkspace { + return DevWorkspace( + DevWorkspaceObjectMeta( + name = "test-workspace", + namespace = "test-namespace", + uid = "test-uid", + annotations = mapOf("che.eclipse.org/che-editor" to cheEditor), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolverTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolverTest.kt new file mode 100644 index 00000000..7c1e7fdd --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolverTest.kt @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.devworkspace + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class WorkspaceEditorResolverTest { + + private lateinit var devWorkspaces: DevWorkspaces + private val resolvedEditors = mutableListOf>() + private val dispatchEdt: (() -> Unit) -> Unit = { it() } + + @BeforeEach + fun setUp() { + devWorkspaces = mockk(relaxed = true) + resolvedEditors.clear() + } + + @Test + fun `resolve returns JETBRAINS when templates are seeded for workspace uid`() { + // given + val dw = workspace("w1", "ns", uid = "uid1") + val resolver = resolver(CoroutineScope(Dispatchers.Unconfined)) + resolver.seedTemplateCache( + mapOf("ns" to mapOf("uid1" to listOf(jetbrainsTemplate("ns", "uid1")))), + emptySet() + ) + + // when + val info = resolver.resolve(dw) + + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + } + + @Test + fun `resolve returns UNKNOWN when no templates are cached`() { + // given + val resolver = resolver(CoroutineScope(Dispatchers.Unconfined)) + + // when + val info = resolver.resolve(workspace("w1", "ns", uid = "uid1")) + + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + } + + @Test + fun `resolve uses che-editor annotation when no templates are cached`() { + // given + val resolver = resolver(CoroutineScope(Dispatchers.Unconfined)) + + // when + val info = resolver.resolve(workspace("w1", "ns", cheEditor = "eclipse/che-idea-server/latest")) + + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + } + + @Test + fun `seedTemplateCache populates unavailable namespaces`() { + // given + val resolver = resolver(CoroutineScope(Dispatchers.Unconfined)) + + // when + resolver.seedTemplateCache(emptyMap(), setOf("ns1")) + + // then + assertThat(resolver.templatesUnavailable("ns1")).isTrue() + assertThat(resolver.templatesUnavailable("ns2")).isFalse() + } + + @Test + fun `background fetch coalesces concurrent fetches per namespace`() { + // given — the first fetch blocks inside loadTemplates so the second stays in flight + val started = CountDownLatch(1) + val release = CountDownLatch(1) + val loadCalls = AtomicInteger() + every { devWorkspaces.loadTemplates("ns") } answers { + loadCalls.incrementAndGet() + started.countDown() + release.await() + Templates(emptyMap(), unavailable = true) + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + try { + val resolver = resolver(scope) + val dw = workspace("w1", "ns", uid = "uid1") + + // when — second fetch arrives while the first is still in flight + resolver.backgroundFetchTemplatesAndPatch(dw) + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue() + resolver.backgroundFetchTemplatesAndPatch(dw) + + // then + assertThat(loadCalls.get()).isEqualTo(1) + } finally { + release.countDown() + scope.cancel() + } + } + + @Test + fun `background fetch marks namespace unavailable and skips callback when templates fail`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val resolver = resolver(this) + + // when + resolver.backgroundFetchTemplatesAndPatch(workspace("w1", "ns", uid = "uid1")) + advanceUntilIdle() + + // then + assertThat(resolver.templatesUnavailable("ns")).isTrue() + assertThat(resolvedEditors).isEmpty() + } + + @Test + fun `background fetch updates cache and dispatches resolved editor`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates( + mapOf("uid1" to listOf(jetbrainsTemplate("ns", "uid1"))), + unavailable = false + ) + val resolver = resolver(this) + val dw = workspace("w1", "ns", uid = "uid1") + + // when + resolver.backgroundFetchTemplatesAndPatch(dw) + advanceUntilIdle() + + // then + assertThat(resolver.resolve(dw).kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + assertThat(resolver.templatesUnavailable("ns")).isFalse() + assertThat(resolvedEditors).hasSize(1) + assertThat(resolvedEditors.single().first).isEqualTo(dw) + assertThat(resolvedEditors.single().second.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + } + + @Test + fun `background fetch updates cache but does not dispatch when editor stays unknown`() = runTest { + // given + // Templates are available but none match the workspace uid -> editor stays UNKNOWN. + every { devWorkspaces.loadTemplates("ns") } returns Templates( + mapOf("other-uid" to listOf(jetbrainsTemplate("ns", "other-uid"))), + unavailable = false + ) + val resolver = resolver(this) + val dw = workspace("w1", "ns", uid = "uid1") + + // when + resolver.backgroundFetchTemplatesAndPatch(dw) + advanceUntilIdle() + + // then + assertThat(resolver.resolve(dw).kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(resolver.templatesUnavailable("ns")).isFalse() + assertThat(resolvedEditors).isEmpty() + } + + @Test + fun `background fetch patches other tracked workspaces that now resolve`() = runTest { + // given — w1 triggers the fetch; w2 (same namespace) was resolved earlier as UNKNOWN + every { devWorkspaces.loadTemplates("ns") } returns Templates( + mapOf( + "uid1" to listOf(jetbrainsTemplate("ns", "uid1")), + "uid2" to listOf(jetbrainsTemplate("ns", "uid2")) + ), + unavailable = false + ) + val resolver = resolver(this) + val dw1 = workspace("w1", "ns", uid = "uid1") + val dw2 = workspace("w2", "ns", uid = "uid2") + assertThat(resolver.resolve(dw1).kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(resolver.resolve(dw2).kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + + // when + resolver.backgroundFetchTemplatesAndPatch(dw1) + advanceUntilIdle() + + // then — both the triggering workspace and the coalesced one are patched + assertThat(resolvedEditors.map { it.first }) + .containsExactlyInAnyOrder(dw1, dw2) + assertThat(resolvedEditors.map { it.second.kind }) + .containsExactlyInAnyOrder( + WorkspaceEditorKind.JETBRAINS, + WorkspaceEditorKind.JETBRAINS + ) + resolver.untrack(dw2) + } + + @Test + fun `background fetch does not patch workspaces in other namespaces`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates( + mapOf("uid1" to listOf(jetbrainsTemplate("ns", "uid1"))), + unavailable = false + ) + val resolver = resolver(this) + val dw1 = workspace("w1", "ns", uid = "uid1") + val dwOther = workspace("w2", "other-ns", uid = "other-ns/w2") + assertThat(resolver.resolve(dw1).kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + assertThat(resolver.resolve(dwOther).kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + + // when + resolver.backgroundFetchTemplatesAndPatch(dw1) + advanceUntilIdle() + + // then + assertThat(resolvedEditors.map { it.first }).containsExactly(dw1) + } + + @Test + fun `cancelInFlight is safe when nothing is in flight`() = runTest { + // given + val resolver = resolver(this) + + // when/then + resolver.cancelInFlight() + assertThat(resolver.templatesUnavailable("ns")).isFalse() + } + + @Test + fun `background fetch uses latest tracked workspace after refresh`() { + // given — block loadTemplates so we can refresh before the fetch completes + val started = CountDownLatch(1) + val release = CountDownLatch(1) + every { devWorkspaces.loadTemplates("ns") } answers { + started.countDown() + release.await() + Templates( + mapOf("uid1" to listOf(jetbrainsTemplate("ns", "uid1"))), + unavailable = false + ) + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + try { + val resolver = resolver(scope) + val dwStarting = workspace("w1", "ns", uid = "uid1", phase = "Starting") + val dwRunning = workspace("w1", "ns", uid = "uid1", phase = "Running") + + // when — resolve starting phase, trigger background fetch, then refresh to running + resolver.resolve(dwStarting) + resolver.backgroundFetchTemplatesAndPatch(dwStarting) + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue() + resolver.refreshTracked(dwRunning) + release.countDown() + Thread.sleep(200) + + // then — the dispatched patch should use the refreshed Running phase workspace + assertThat(resolvedEditors).hasSize(1) + assertThat(resolvedEditors.single().first.phase).isEqualTo("Running") + assertThat(resolvedEditors.single().first).isEqualTo(dwRunning) + assertThat(resolvedEditors.single().second.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + } finally { + scope.cancel() + } + } + + private fun resolver(scope: CoroutineScope): WorkspaceEditorResolver { + return WorkspaceEditorResolver( + devWorkspaces = devWorkspaces, + scope = scope, + onEditorResolved = { patches -> resolvedEditors += patches }, + dispatchEdt = dispatchEdt + ) + } + + private fun workspace( + name: String, + namespace: String, + uid: String = "$namespace/$name", + cheEditor: String? = null, + phase: String = "Running" + ): DevWorkspace { + return DevWorkspace( + DevWorkspaceObjectMeta( + name = name, + namespace = namespace, + uid = uid, + annotations = if (cheEditor != null) mapOf("che.eclipse.org/che-editor" to cheEditor) else emptyMap(), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = phase) + ) + } + + private fun jetbrainsTemplate(namespace: String, ownerUid: String): DevWorkspaceTemplate { + return DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata( + name = "jetbrains-template", + namespace = namespace, + pluginRegistryUrl = null, + ownerRefencesUids = listOf(ownerUid) + ), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt index 6001cd34..9f09116a 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt @@ -88,6 +88,24 @@ class RemoteIDEServerTest { } } + @Test + fun `#waitServerReady fails fast when idea-server container is missing`() { + // given — refreshing the pod/container during the ready-wait finds no idea-server container + every { + remoteIDEServer["findContainer"]() + } throws ServerContainerNotFoundException("Workspace IDE container not found in the Pod: test-pod") + + // when + val exception = assertThrows { + runBlocking { + remoteIDEServer.waitServerReady(timeout = 5) + } + } + + // then — fails immediately instead of retrying until the ready timeout elapses + assertThat(exception.message).contains("Workspace IDE container not found") + } + @Test fun `#waitServerReady should NOT reach timeout and throw if server status has a join link but no projects`() { // given diff --git a/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModelTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModelTest.kt new file mode 100644 index 00000000..97ba7fae --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableModelTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.redhat.devtools.gateway.devworkspace.DevWorkspace +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceObjectMeta +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceSpec +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceStatus +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorInfo +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DevWorkspaceTableModelTest { + + @Test + fun `addAll keeps items sorted by editor priority then namespace then name`() { + // given + val model = DevWorkspaceTableModel() + model.addAll( + listOf( + item("a", "ns1", WorkspaceEditorKind.UNKNOWN), + item("b", "ns1", WorkspaceEditorKind.JETBRAINS), + item("a", "ns0", WorkspaceEditorKind.UNKNOWN), + item("c", "ns2", WorkspaceEditorKind.VSCODE), + item("a", "ns1", WorkspaceEditorKind.JETBRAINS) + ) + ) + + // then + assertThat(model.getRowCount()).isEqualTo(5) + assertThat(names(model)).containsExactly("a", "b", "a", "a", "c") + assertThat(namespaces(model)).containsExactly("ns1", "ns1", "ns0", "ns1", "ns2") + assertThat(kinds(model)).containsExactly( + WorkspaceEditorKind.JETBRAINS, + WorkspaceEditorKind.JETBRAINS, + WorkspaceEditorKind.UNKNOWN, + WorkspaceEditorKind.UNKNOWN, + WorkspaceEditorKind.VSCODE + ) + } + + @Test + fun `add inserts item at given index`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("c"))) + + // when + model.add(1, item("b")) + + // then + assertThat(names(model)).containsExactly("a", "b", "c") + } + + @Test + fun `set replaces item at index and keeps row count`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("b"))) + + // when + model.set(0, item("z")) + + // then — sort key changed, row repositioned to keep the table sorted + assertThat(model.getRowCount()).isEqualTo(2) + assertThat(names(model)).containsExactly("b", "z") + } + + @Test + fun `set with unchanged sort key updates row in place`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("b"))) + + // when + model.set(0, item("a", namespace = "ns", editor = WorkspaceEditorKind.UNKNOWN)) + + // then — sort key identical, no repositioning + assertThat(names(model)).containsExactly("a", "b") + } + + @Test + fun `remove deletes item at index`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("b"), item("c"))) + + // when + model.remove(1) + + // then + assertThat(names(model)).containsExactly("a", "c") + } + + @Test + fun `clear removes all rows`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("b"))) + + // when + model.clear() + + // then + assertThat(model.getRowCount()).isZero() + } + + @Test + fun `indexOfFirst returns index of matching item`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"), item("b"))) + + // when + val index = model.indexOfFirst { it.workspace.name == "b" } + + // then + assertThat(index).isEqualTo(1) + } + + @Test + fun `indexOfFirst returns minus one when nothing matches`() { + // given + val model = DevWorkspaceTableModel() + model.addAll(listOf(item("a"))) + + // when + val index = model.indexOfFirst { it.workspace.name == "missing" } + + // then + assertThat(index).isEqualTo(-1) + } + + @Test + fun `getValueAt returns the item for any cell of a row`() { + // given + val model = DevWorkspaceTableModel() + val expected = item("a") + model.addAll(listOf(expected)) + + // when/then + assertThat(model.getValueAt(0, 0)).isSameAs(expected) + assertThat(model.getValueAt(0, 3)).isSameAs(expected) + } + + @Test + fun `getColumnCount is four`() { + assertThat(DevWorkspaceTableModel().getColumnCount()).isEqualTo(4) + } + + private fun names(model: DevWorkspaceTableModel): List = + (0 until model.getRowCount()).map { model[it].workspace.name } + + private fun namespaces(model: DevWorkspaceTableModel): List = + (0 until model.getRowCount()).map { model[it].workspace.namespace } + + private fun kinds(model: DevWorkspaceTableModel): List = + (0 until model.getRowCount()).map { model[it].editor.kind } + + private fun item( + name: String, + namespace: String = "ns", + editor: WorkspaceEditorKind = WorkspaceEditorKind.UNKNOWN + ): DevWorkspaceListItem { + return DevWorkspaceListItem( + workspace = DevWorkspace( + DevWorkspaceObjectMeta( + name = name, + namespace = namespace, + uid = "$namespace/$name", + annotations = emptyMap(), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ), + editor = WorkspaceEditorInfo(editor, editor.name) + ) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdaterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdaterTest.kt new file mode 100644 index 00000000..acd127f8 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableUpdaterTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.workspaces + +import com.redhat.devtools.gateway.devworkspace.DevWorkspace +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceObjectMeta +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceSpec +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceStatus +import com.redhat.devtools.gateway.devworkspace.DevWorkspaces +import com.redhat.devtools.gateway.devworkspace.Templates +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind +import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorResolver +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DevWorkspaceTableUpdaterTest { + + private lateinit var devWorkspaces: DevWorkspaces + private lateinit var model: DevWorkspaceTableModel + + @BeforeEach + fun setUp() { + devWorkspaces = mockk(relaxed = true) + model = DevWorkspaceTableModel() + } + + @Test + fun `onAdded inserts workspace with editor resolved from annotation`() = runTest { + // given + val updater = updater(this) + val dw = workspace("w1", "ns", cheEditor = "eclipse/che-idea-server/latest") + + // when + updater.onAdded(dw) + + // then + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].workspace).isEqualTo(dw) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + } + + @Test + fun `onAdded inserts unknown workspace and triggers background template fetch`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + + // when + updater.onAdded(workspace("w1", "ns")) + advanceUntilIdle() + + // then + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + verify { devWorkspaces.loadTemplates("ns") } + } + + @Test + fun `onAdded does not fetch when namespace is known to have no templates`() = runTest { + // given + val resolver = resolver(this) + resolver.seedTemplateCache(emptyMap(), setOf("ns")) + val updater = DevWorkspaceTableUpdater(model, resolver) + + // when + updater.onAdded(workspace("w1", "ns")) + advanceUntilIdle() + + // then + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + verify(exactly = 0) { devWorkspaces.loadTemplates("ns") } + } + + @Test + fun `onAdded keeps model sorted`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + + // when + updater.onAdded(workspace("b", "ns", cheEditor = "eclipse/che-idea-server/latest")) // JETBRAINS-family, prio 0 + advanceUntilIdle() + updater.onAdded(workspace("a", "ns")) // UNKNOWN, prio 1 + advanceUntilIdle() + + // then + assertThat(model.getRowCount()).isEqualTo(2) + assertThat(model[0].workspace.name).isEqualTo("b") + assertThat(model[1].workspace.name).isEqualTo("a") + } + + @Test + fun `onUpdated preserves previously resolved editor`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + updater.onAdded(workspace("w1", "ns", cheEditor = "eclipse/che-idea-server/latest")) + advanceUntilIdle() + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + + // when — same workspace identity (name+namespace), different phase, no annotation + updater.onUpdated(workspace("w1", "ns", phase = "Failed")) + + // then — editor must not flip to UNKNOWN (CRW-11897) + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + assertThat(model[0].workspace.phase).isEqualTo("Failed") + } + + @Test + fun `onUpdated resolves and inserts workspace missing from model`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + + // when — workspace not in model (missed ADDED), no background fetch needed (annotation) + updater.onUpdated(workspace("w1", "ns", cheEditor = "eclipse/che-idea-server/latest")) + advanceUntilIdle() + + // then + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + } + + @Test + fun `onUpdated resolves and inserts unknown workspace missing from model`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + + // when + updater.onUpdated(workspace("w1", "ns")) + advanceUntilIdle() + + // then + assertThat(model.getRowCount()).isEqualTo(1) + assertThat(model[0].editor.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + } + + @Test + fun `onDeleted removes workspace from model`() = runTest { + // given + every { devWorkspaces.loadTemplates("ns") } returns Templates(emptyMap(), unavailable = true) + val updater = updater(this) + updater.onAdded(workspace("w1", "ns", cheEditor = "eclipse/che-idea-server/latest")) + advanceUntilIdle() + assertThat(model.getRowCount()).isEqualTo(1) + + // when + updater.onDeleted(workspace("w1", "ns")) + + // then + assertThat(model.getRowCount()).isZero() + } + + private fun resolver(scope: TestScope): WorkspaceEditorResolver { + return WorkspaceEditorResolver( + devWorkspaces = devWorkspaces, + scope = scope, + onEditorResolved = { _ -> }, + dispatchEdt = { it() } + ) + } + + private fun updater(scope: TestScope): DevWorkspaceTableUpdater { + return DevWorkspaceTableUpdater(model, resolver(scope)) + } + + private fun workspace( + name: String, + namespace: String, + phase: String = "Running", + cheEditor: String? = null + ): DevWorkspace { + return DevWorkspace( + DevWorkspaceObjectMeta( + name = name, + namespace = namespace, + uid = "$namespace/$name", + annotations = if (cheEditor != null) mapOf("che.eclipse.org/che-editor" to cheEditor) else emptyMap(), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = phase) + ) + } +} \ No newline at end of file