diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatcher.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManager.kt similarity index 61% rename from src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatcher.kt rename to src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManager.kt index ac1197ad..169bce75 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatcher.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManager.kt @@ -18,22 +18,63 @@ import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.util.Watch import kotlinx.coroutines.* +internal class DevWorkspaceWatchManager( + private val createWatch: (String, String?) -> Watch, + private val listener: DevWorkspaceListener, + private val scope: CoroutineScope +) { + private val watchers = mutableListOf() + + /** + * Starts a watch for each namespace in [lastResourceVersions]. + * + * This is a replace-all operation: any previously started watches are + * stopped first via [stop] before the new ones are created, so calling + * this method repeatedly does not accumulate duplicate watchers. + * + * @param lastResourceVersions Maps a namespace to the resource version to resume + * its watch from. Entries with a `null` resource version are skipped. + */ + fun start(lastResourceVersions: Map = emptyMap()) { + // Idempotent: never accumulate duplicate watchers per namespace. + stop() + val toStart = lastResourceVersions + .filterValues { it != null } + .map { (ns, resourceVersion) -> + DevWorkspaceWatch( + namespace = ns, + createWatcher = createWatch, + listener = listener, + scope = scope + ) to resourceVersion + } + watchers += toStart.map { (watch, _) -> watch } + toStart.forEach { (watch, resourceVersion) -> watch.start(resourceVersion) } + } + + fun stop() { + watchers.forEach { it.stop() } + watchers.clear() + } +} + interface DevWorkspaceListener { fun onAdded(dw: DevWorkspace) fun onUpdated(dw: DevWorkspace) fun onDeleted(dw: DevWorkspace) } -class DevWorkspaceWatcher( +internal class DevWorkspaceWatch( private val namespace: String, private val createWatcher: (namespace: String, latestResourceVersion: String?) -> Watch, - private val createFilter: (String) -> ((DevWorkspace) -> Boolean), private val listener: DevWorkspaceListener, private val scope: CoroutineScope, ) { private var job: Job? = null @Volatile private var stopped = false + @Volatile + private var currentWatcher: Watch? = null fun start(latestResourceVersion: String? = null) { stopped = false @@ -44,6 +85,14 @@ class DevWorkspaceWatcher( fun stop() { stopped = true + // Closing the stream unblocks a thread stuck in Watch.hasNext() immediately + // instead of waiting for the OkHttp read timeout. + try { + currentWatcher?.close() + } catch (_: Exception) { + // best effort + } + currentWatcher = null job?.cancel() job = null } @@ -52,20 +101,17 @@ class DevWorkspaceWatcher( while (scope.isActive && !stopped) { try { val watcher = createWatcher(namespace, latestResourceVersion) + currentWatcher = watcher watcher.use { watcher -> - var matches = createFilter(namespace) for (event in watcher) { if (!scope.isActive || stopped) break val dw = DevWorkspace.from(event.`object`) - if (event.type == "ADDED") { - matches = createFilter(namespace) - } withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) { if (stopped) return@withContext when (event.type) { - "ADDED" -> if(matches(dw)) listener.onAdded(dw) - "MODIFIED" -> if(matches(dw)) listener.onUpdated(dw) else listener.onDeleted(dw) + "ADDED" -> listener.onAdded(dw) + "MODIFIED" -> listener.onUpdated(dw) "DELETED" -> listener.onDeleted(dw) } } @@ -83,41 +129,12 @@ class DevWorkspaceWatcher( // Other Kubernetes API errors — retry. } catch (_: Exception) { // Connection dropped or closed — reconnect. + } finally { + currentWatcher = null } @Suppress("ConvertLongToDuration") delay(100) } } -} - -class DevWorkspaceWatchManager( - private val createWatcher: (String, String?) -> Watch, - private val createFilter: (String) -> ((DevWorkspace) -> Boolean), - private val listener: DevWorkspaceListener, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) -) { - private val watchers = mutableListOf() - - fun start(lastResourceVersions: Map = emptyMap()) { - lastResourceVersions.forEach { (ns, resourceVersion) -> - if (resourceVersion == null) { - return@forEach - } - val w = DevWorkspaceWatcher( - namespace = ns, - createWatcher = createWatcher, - createFilter = createFilter, - listener = listener, - scope = scope - ) - watchers += w - w.start(resourceVersion) - } - } - - fun stop() { - watchers.forEach { it.stop() } - watchers.clear() - } } \ No newline at end of file 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 8b87b96b..233802c3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt @@ -315,7 +315,8 @@ class DevWorkspaces(private val client: ApiClient) { ) } -/** Returns `true` if the given exception is ignorable when listing templates. + /** + * 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. @@ -327,7 +328,8 @@ class DevWorkspaces(private val client: ApiClient) { private fun ApiException.isIgnorableTemplateListError(): Boolean = isUnauthorized() || isForbidden() || isNotFound() - /** Returns `true` if the given exception is skippable when listing devworkspaces + /** + * 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), diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt index a29da367..97553780 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProvider.kt @@ -13,22 +13,22 @@ 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, +enum class WorkspaceEditorKind(val isConnectable: Boolean) { + VSCODE(false), + INTELLIJ_IDEA(true), + PYCHARM(true), + CLION(true), + GOLAND(true), + PHPSTORM(true), + RIDER(true), + RUBYMINE(true), + WEBSTORM(true), + CHEMUXER(false), + HERDR(true), + KIRO(true), + WEB_TERMINAL(false), + JETBRAINS(true), + UNKNOWN(true) } data class WorkspaceEditorInfo( @@ -36,71 +36,130 @@ data class WorkspaceEditorInfo( val tooltip: String, ) -private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE) - object WorkspaceEditorInfoProvider { + private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE) + 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) + val editorTemplateAnnotation = Utils.getValue( + devWorkspace.annotations, + arrayOf("che.eclipse.org/che-editor-template") + ) as? String + if (!editorTemplateAnnotation.isNullOrBlank()) { + createFromEditorTemplateAnnotation(editorTemplateAnnotation, devWorkspace, templateMap)?.let { return it } + } + + val editorAnnotation = Utils.getValue( + devWorkspace.annotations, + arrayOf("che.eclipse.org/che-editor") + ) as? String + if (!editorAnnotation.isNullOrBlank()) { + createFromEditorAnnotation(editorAnnotation).let { return it } } - if (isJetBrainsEditor(devWorkspace, templateMap)) { + + if (editorTemplateAnnotation.isNullOrBlank() + && isJetBrainsEditor(devWorkspace, templateMap)) { return WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") } + return WorkspaceEditorInfo(WorkspaceEditorKind.UNKNOWN, "Unknown Editor") } - fun isJetBrainsEditor( + internal fun isJetBrainsEditor( devWorkspace: DevWorkspace, templateMap: Map> ): Boolean { - // DevWorkspace Template check - val templates = templateMap[devWorkspace.uid] ?: return false + 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) - } + hasIdeaServerComponent(template) } } - private fun createFromAnnotation(cheEditor: String): WorkspaceEditorInfo { - val editorName = extractEditorName(cheEditor) + private fun hasIdeaServerComponent(template: DevWorkspaceTemplate): Boolean { + @Suppress("UNCHECKED_CAST") + val components = template.components as? List + ?: return false + return components.any { component: Any -> + val map = component as? Map<*, *> + ?: return@any false + val volume = map["volume"] as? Map<*, *> + // Check 'volume.name' first (v1alpha1) + val name = volume?.get("name") as? String + // fallback to top-level 'name' (v1alpha2) + ?: map["name"] as? String + name.equals("idea-server", ignoreCase = true) + } + } + + /** + * Resolves the editor from the workspace's editor template annotation. + * + * Only JetBrains editors are resolved here, via an `idea-server` component check on the + * annotated template. Returns `null` when the template is not found or lacks `idea-server`, + * in which case the caller falls through to the `che-editor` annotation only; the + * `isJetBrainsEditor` template scan is skipped, so unrelated templates carrying an + * `idea-server` component cannot override the annotated template choice. + * This is intentionally scoped to JetBrains editor resolution. + * + * @param editorTemplateAnnotation the value of the editor template annotation + * @param devWorkspace the workspace owning the annotation + * @param templateMap map of owner UID to templates for the workspace + * @return a JetBrains [WorkspaceEditorInfo], or `null` so the caller falls through + */ + private fun createFromEditorTemplateAnnotation( + editorTemplateAnnotation: String, + devWorkspace: DevWorkspace, + templateMap: Map> + ): WorkspaceEditorInfo? { + val annotatedTemplate = templateMap[devWorkspace.uid]?.firstOrNull { + it.name.equals(editorTemplateAnnotation, ignoreCase = true) + } + if (annotatedTemplate != null + && hasIdeaServerComponent(annotatedTemplate)) { + return WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") + } + return null + } + + private fun createFromEditorAnnotation(cheEditor: String): WorkspaceEditorInfo { + val cheEditorSegments = cheEditor.split("/") + + val editorName = extractEditorName(cheEditorSegments) if (editorName != null) { createFromEditorName(editorName)?.let { return it } } - if (cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) { + + if (cheEditorSegments.any { CHE_EDITOR_ID_REGEX.matches(it) }) { return WorkspaceEditorInfo(WorkspaceEditorKind.JETBRAINS, "JetBrains") } - val fallbackSegment = cheEditor.split("/").lastOrNull { it.isNotBlank() } + + val fallbackSegment = cheEditorSegments.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] + private fun extractEditorName(cheEditorSegments: List): String? { + val parts = cheEditorSegments.filter { it.isNotBlank() } + return if (parts.size >= 3) { + parts[1] + } else { + parts.firstOrNull { + CHE_EDITOR_ID_REGEX.matches(it) + || it.startsWith("che-", ignoreCase = true) + } } - 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-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") -> diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt index 5f91006b..da883fdd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorResolver.kt @@ -19,8 +19,9 @@ 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). + * Resolves [WorkspaceEditorInfo] for watched DevWorkspaces, caches templates per namespace + * and coalesces 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. @@ -35,7 +36,19 @@ internal class WorkspaceEditorResolver( ) { @Volatile var templateMapsByNamespace: Map>> = emptyMap() + /** + * Namespaces where template loading failed or don't exist + * + * @see [backgroundFetchTemplatesAndPatch] + * @see [templatesUnavailable] + */ private val templatesUnavailableNamespaces: MutableSet = ConcurrentHashMap.newKeySet() + /** + * Ensures at most one in-flight fetch per namespace; different namespaces fetch in parallel. + * + * @see [acquireFetchSlot] + * @see [releaseFetchSlot] + */ private val templateFetchInFlight: ConcurrentHashMap = ConcurrentHashMap() private val trackedWorkspaces: ConcurrentHashMap = ConcurrentHashMap() @@ -50,6 +63,13 @@ internal class WorkspaceEditorResolver( templatesUnavailableNamespaces.addAll(unavailableNamespaces) } + /** + * Returns `true` if templates for the given namespace are unavailable. Returns `false` otherwise. + * Allows callers skip pointless re-fetches and editor patches for workspaces in that namespace. + * + * @param namespace for the templates + * @return true if templates for the given namespace are unavailable. + */ fun templatesUnavailable(namespace: String): Boolean = namespace in templatesUnavailableNamespaces fun resolve(dw: DevWorkspace): WorkspaceEditorInfo { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt index da6524af..576cb873 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt @@ -60,7 +60,8 @@ class RemoteIDEServerReadiness( logWaitingForState(isReadyState, timeout) val refreshFailures = intArrayOf(0) var pollCount = 0 - var elapsedMillis = 0L + val startNanos = System.nanoTime() + val elapsedMillis = { (System.nanoTime() - startNanos) / 1_000_000L } while (true) { checkCancelled?.invoke() // On a transient refresh failure the probe is skipped for this @@ -75,16 +76,15 @@ class RemoteIDEServerReadiness( false } if (stateReached) { - logStateReached(isReadyState, elapsedMillis) + logStateReached(isReadyState, elapsedMillis()) return@withTimeoutOrNull true } } pollCount++ - logStillWaiting(pollCount, elapsedMillis, timeout) + logStillWaiting(pollCount, elapsedMillis(), timeout) yield() val delayMillis = backoff.nextDelayMillis() - elapsedMillis += delayMillis delay(delayMillis) } 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 42c4ab13..c5120ed7 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 @@ -12,6 +12,8 @@ package com.redhat.devtools.gateway.view.steps 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 @@ -20,16 +22,10 @@ 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.devworkspace.DevWorkspace -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.devworkspace.* import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.Utils import com.redhat.devtools.gateway.server.RemoteIDEServer @@ -37,9 +33,9 @@ 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.DevWorkspaceTableController 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 @@ -48,39 +44,12 @@ import kotlinx.coroutines.runBlocking import java.awt.Dimension import java.util.concurrent.CancellationException import javax.swing.JButton -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() { - val label = Utils.getValue(this.labels, arrayOf("kubernetes.io/metadata.name")) as String? - return label ?: this.name - } - class DevSpacesWorkspacesStepView( private var devSpacesContext: DevSpacesContext, private val enableNextButton: (() -> Unit)? @@ -95,7 +64,8 @@ class DevSpacesWorkspacesStepView( private lateinit var startDevWorkspaceButton: JButton private lateinit var stopDevWorkspaceButton: JButton - private var watchManager: WorkspacesWatch? = null + private var watchManager: DevWorkspaceTableController? = null + private var cachedWatchResourceVersions: Map = emptyMap() override val component = panel { row { @@ -138,7 +108,10 @@ class DevSpacesWorkspacesStepView( override fun onInit() { devWorkspacesTableModel.clear() // avoid glitch where user would see old list content before it's cleared - devWorkspacesTable.selectionModel.addListSelectionListener(DevWorkspaceSelection()) + devWorkspacesTable.selectionModel.addListSelectionListener(ListSelectionListener { + enableButtons() + refreshNextButton() + }) devWorkspacesTable.onDoubleClick { onNext() } @@ -146,7 +119,7 @@ class DevSpacesWorkspacesStepView( initTableListeners(this) watchManager?.dispose() - watchManager = WorkspacesWatch(devSpacesContext.client, devWorkspacesTableModel) + watchManager = DevWorkspaceTableController(devSpacesContext.client, devWorkspacesTableModel) refreshAndWatchAllDevWorkspaces() enableButtons() } @@ -159,7 +132,7 @@ class DevSpacesWorkspacesStepView( override fun onNext(): Boolean { val item = getSelectedWorkspaceListItem() ?: return false val workspace = item.workspace - if (!item.editor.kind.isConnectableEditor()) { + if (!item.editor.kind.isConnectable) { return false } if (!isRunning(workspace)) { @@ -198,11 +171,8 @@ class DevSpacesWorkspacesStepView( val selectionListener = ListSelectionListener { enableButtons() } devWorkspacesTable.selectionModel.addListSelectionListener(selectionListener) - val dataListener = object : TableModelListener { - override fun tableChanged(e: TableModelEvent) = enableButtons() - } + val dataListener = TableModelListener { enableButtons() } devWorkspacesTableModel.addTableModelListener(dataListener) - Disposer.register(disposable) { devWorkspacesTable.selectionModel.removeListSelectionListener(selectionListener) devWorkspacesTableModel.removeTableModelListener(dataListener) @@ -231,46 +201,59 @@ class DevSpacesWorkspacesStepView( } private fun refreshAllDevWorkspaces(): Map { - val lastResourceVersions = mutableMapOf() - val templateMaps = mutableMapOf>>() - val namespacesUnavailable = mutableSetOf() - val devWorkspaces = Projects(devSpacesContext.client).list() + val state = DevWorkspaceRefreshState() + val projects = Projects(devSpacesContext.client).list() + val devWorkspaces = projects .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 - } + .flatMap { fetchDevWorkspacesForNamespace(it, state) } + + thisLogger().info( + "Starting DevWorkspace watches: ${state.lastResourceVersions.size} namespaces" + + " of ${projects.size} projects listed (${state.workspaceCount} workspaces)" + ) 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) - } + devWorkspacesTable.replaceItems(devWorkspaces) } - watchManager?.seedTemplateCache(templateMaps, namespacesUnavailable) + watchManager?.seedTemplateCache( + state.templateMaps, + state.namespacesUnavailable + ) - return lastResourceVersions + cachedWatchResourceVersions = state.lastResourceVersions + return state.lastResourceVersions } - private fun getValidSelectedIndex(selectedIndex: Int): Int { - return if (selectedIndex >= 0 - && selectedIndex < devWorkspacesTableModel.getRowCount()) { - selectedIndex - } else { - if (devWorkspacesTableModel.getRowCount() > 0) 0 else -1 + private class DevWorkspaceRefreshState { + val lastResourceVersions = mutableMapOf() + val templateMaps = mutableMapOf>>() + val namespacesUnavailable = mutableSetOf() + var workspaceCount = 0 + } + + private fun fetchDevWorkspacesForNamespace( + namespace: String, + state: DevWorkspaceRefreshState + ): List { + val dwListResult = DevWorkspaces(devSpacesContext.client).listWithResult(namespace) + state.templateMaps[namespace] = dwListResult.templates + if (dwListResult.templatesUnavailable) { + state.namespacesUnavailable.add(namespace) + } + val resourceVersion = dwListResult.resourceVersion + /* + * Known gap: + * New workspaces in previously empty namespaces are not shown. + * We're not watching empty namespaces for now. + * Only manual refresh makes them show up in the list of workspaces + */ + if (dwListResult.items.isNotEmpty() + && resourceVersion != null) { + state.lastResourceVersions[namespace] = resourceVersion } + state.workspaceCount += dwListResult.items.size + return dwListResult.items } private fun refreshDevWorkspace(namespace: String, name: String) { @@ -349,14 +332,19 @@ class DevSpacesWorkspacesStepView( } val remoteIdeServer = RemoteIDEServer(devSpacesContext) - status = runBlocking { - // Progress text stays visible for the whole wait; update so a long poll - // does not look frozen while RemoteIDEServer probes status. - progressIndicator.text = - "Waiting for workspace IDE to become ready (up to ${RemoteIDEServer.readyTimeout}s)..." - remoteIdeServer.waitServerReady(checkCancelled) - progressIndicator.text = "Reading workspace IDE status..." - remoteIdeServer.getStatus(checkCancelled) + watchManager?.stop() + try { + status = runBlocking { + // Progress text stays visible for the whole wait; update so a long poll + // does not look frozen while RemoteIDEServer probes status. + progressIndicator.text = + "Waiting for workspace IDE to become ready (up to ${RemoteIDEServer.readyTimeout}s)..." + remoteIdeServer.waitServerReady(checkCancelled) + progressIndicator.text = "Reading workspace IDE status..." + remoteIdeServer.getStatus(checkCancelled) + } + } finally { + watchManager?.start(cachedWatchResourceVersions) } } catch (e: Exception) { if (e.isCancellationException()) { @@ -390,16 +378,21 @@ class DevSpacesWorkspacesStepView( ProgressManager.getInstance().runProcessWithProgressSynchronously( { try { - runBlocking(Dispatchers.IO) { - DevSpacesConnection(devSpacesContext).connect( - { refreshSelectedAndButtons() }, - { enableButtons() }, - { - if (waitDevWorkspaceStopped(devSpacesContext.devWorkspace)) { - refreshSelectedAndButtons() + watchManager?.stop() + try { + runBlocking(Dispatchers.IO) { + DevSpacesConnection(devSpacesContext).connect( + { refreshSelectedAndButtons() }, + { enableButtons() }, + { + if (waitDevWorkspaceStopped(devSpacesContext.devWorkspace)) { + refreshSelectedAndButtons() + } } - } - ) + ) + } + } finally { + watchManager?.start(cachedWatchResourceVersions) } } catch (e: Exception) { refreshSelectedAndButtons() @@ -452,7 +445,7 @@ class DevSpacesWorkspacesStepView( override fun isNextEnabled(): Boolean { val item = getSelectedWorkspaceListItem() ?: return false - return item.editor.kind.isConnectableEditor() && isRunning(item.workspace) + return item.editor.kind.isConnectable && isRunning(item.workspace) } private fun isStopped(workspace: DevWorkspace?): Boolean { @@ -471,11 +464,4 @@ class DevSpacesWorkspacesStepView( watchManager?.dispose() watchManager = null } - - inner class DevWorkspaceSelection : ListSelectionListener { - override fun valueChanged(e: ListSelectionEvent) { - enableButtons() - refreshNextButton() - } - } } \ No newline at end of file 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/DevWorkspaceTableController.kt similarity index 92% rename from src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/WorkspacesWatch.kt rename to src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableController.kt index 7ad99ea9..02c56139 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/WorkspacesWatch.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/workspaces/DevWorkspaceTableController.kt @@ -28,7 +28,7 @@ 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( +internal class DevWorkspaceTableController( client: ApiClient, private val workspacesTableModel: DevWorkspaceTableModel, private val dispatchEdt: (() -> Unit) -> Unit = { block -> @@ -36,7 +36,7 @@ internal class WorkspacesWatch( } ) { private val devWorkspaces = DevWorkspaces(client) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val editorResolver = WorkspaceEditorResolver( devWorkspaces = devWorkspaces, @@ -55,13 +55,11 @@ internal class WorkspacesWatch( ) private val watchManager = DevWorkspaceWatchManager( - createWatcher = { ns, latestResourceVersion -> + createWatch = { ns, latestResourceVersion -> devWorkspaces.createWatcher(ns, latestResourceVersion = latestResourceVersion) }, - createFilter = { _ -> - { true } - }, - listener = DevWorkspaceTableUpdater(workspacesTableModel, editorResolver) + listener = DevWorkspaceTableUpdater(workspacesTableModel, editorResolver), + scope = scope ) fun seedTemplateCache( 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 index 3a8370ff..bf9af526 100644 --- 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 @@ -22,7 +22,8 @@ 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], + * Note: + * listener callbacks are already invoked on the EDT by [com.redhat.devtools.gateway.devworkspace.DevWorkspaceWatchManager], * so no additional EDT dispatch is done here. */ internal class DevWorkspaceTableUpdater( 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 index a1099383..928a580b 100644 --- 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 @@ -44,6 +44,12 @@ internal class DevWorkspacesTable( configureColumns() } + val selectedItem: DevWorkspaceListItem? + get() { + val row = selectedRow + return if (row in 0 until devWorkspaceModel.rowCount) devWorkspaceModel[row] else null + } + override fun getToolTipText(event: MouseEvent): String? { val row = rowAtPoint(event.point) val column = columnAtPoint(event.point) @@ -56,12 +62,6 @@ internal class DevWorkspacesTable( } } - 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 @@ -77,6 +77,26 @@ internal class DevWorkspacesTable( JBUI.scale(maxProjectWidth + PROJECT_COLUMN_PADDING) } + fun replaceItems(items: List) { + val selectedRow = selectedRow + devWorkspaceModel.apply { + clear() + addAll(items) + } + updateColumnWidths() + val newSelection = validSelectedIndex(selectedRow) + if (newSelection >= 0) { + setRowSelectionInterval(newSelection, newSelection) + } + } + + private fun validSelectedIndex(selectedIndex: Int): Int = + when { + selectedIndex in 0 until devWorkspaceModel.rowCount -> selectedIndex + devWorkspaceModel.rowCount > 0 -> 0 + else -> -1 + } + private fun configureColumns() { columnModel.getColumn(STATUS_COLUMN).cellRenderer = StatusCellRenderer() columnModel.getColumn(NAME_COLUMN).cellRenderer = diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManagerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManagerTest.kt new file mode 100644 index 00000000..c2291280 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceWatchManagerTest.kt @@ -0,0 +1,196 @@ +/* + * 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.view.steps.workspaces.DevWorkspaceTableModel +import com.redhat.devtools.gateway.view.steps.workspaces.DevWorkspaceTableController +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.util.Watch +import io.mockk.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class DevWorkspaceWatchManagerTest { + + private val testScheduler = TestCoroutineScheduler() + private val testDispatcher = StandardTestDispatcher(testScheduler) + + private fun newListener() = mockk(relaxed = true) + + private fun newWatcher( + scope: CoroutineScope, + createWatcher: (String, String?) -> Watch, + ) = DevWorkspaceWatch( + namespace = "test-namespace", + createWatcher = createWatcher, + listener = newListener(), + scope = scope, + ) + + @Test + fun `DevWorkspaceTableController#dispose cancels the shared scope so watch coroutines stop`() { + // given + val client = mockk(relaxed = true) + val tableModel = mockk(relaxed = true) + val createWatcherCalls = AtomicInteger(0) + + mockkConstructor(DevWorkspaces::class) + try { + every { + anyConstructed().createWatcher(any(), any(), any(), any()) + } answers { + createWatcherCalls.incrementAndGet() + mockk>(relaxed = true) + } + + val watch = DevWorkspaceTableController(client, tableModel) + assertThat(watch.scope.isActive).isTrue() + + watch.start(mapOf("test-ns" to "42")) + awaitCondition { createWatcherCalls.get() >= 1 } + val ticksBeforeDispose = createWatcherCalls.get() + // The retry loop re-creates the watcher roughly every 100 ms, but the + // first re-creation can take longer (mock setup cost), so poll instead + // of relying on a fixed sleep window. + awaitCondition(timeoutMs = 10_000) { createWatcherCalls.get() > ticksBeforeDispose } + + // when + watch.dispose() + Thread.sleep(200) + + // then + assertThat(watch.scope.isActive).isFalse() + val ticksAfterDispose = createWatcherCalls.get() + Thread.sleep(400) + assertThat(createWatcherCalls.get()).isEqualTo(ticksAfterDispose) + } finally { + unmockkConstructor(DevWorkspaces::class) + } + } + + @Test + fun `DevWorkspaceTableController#stop closes active Watch stream`() = runTest(testScheduler) { + // given: watcher started, its stream created and active + val scope = CoroutineScope(SupervisorJob() + testDispatcher) + val watcher = mockk>(relaxed = true) + every { watcher.iterator() } returns mutableListOf>().iterator() + every { watcher.close() } just runs + + val devWorkspaceWatcher = newWatcher(scope) { _, _ -> watcher } + + devWorkspaceWatcher.start("1") + testScheduler.advanceTimeBy(50) + + // when + devWorkspaceWatcher.stop() + + // then: the active stream was closed, which unblocks the watch + verify(atLeast = 1) { watcher.close() } + } + + @Test + fun `double start on DevWorkspaceWatchManager does not duplicate watchers`() = runTest(testScheduler) { + val scope = CoroutineScope(SupervisorJob() + testDispatcher) + var createWatcherCalls = 0 + val manager = DevWorkspaceWatchManager( + createWatch = { _, _ -> + createWatcherCalls++ + mockk>(relaxed = true) + }, + listener = newListener(), + scope = scope, + ) + + val namespaces = mapOf("ns1" to "1", "ns2" to "2") + + // when: start twice; the second start must replace, not accumulate + manager.start(namespaces) + testScheduler.advanceTimeBy(50) + manager.start(namespaces) + testScheduler.advanceTimeBy(50) + manager.stop() + + // then: 2 namespaces x 2 starts, never a duplicate 8 + assertThat(createWatcherCalls).isEqualTo(4) + } + + @Test + fun `stop during blocking watch unblocks quickly`() = runTest(testScheduler) { + // A real dispatcher is required: the blocking iterator must run on a + // separate thread so that stop() can be invoked from the test. + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val unblock = CountDownLatch(1) + val enteredHasNext = AtomicBoolean(false) + val unblocked = AtomicBoolean(false) + + val watcher = mockk>() + every { watcher.iterator() } returns object : MutableIterator> { + override fun hasNext(): Boolean { + enteredHasNext.set(true) + unblock.await(30, TimeUnit.SECONDS) + unblocked.set(true) + return false + } + + override fun next(): Watch.Response = throw NoSuchElementException() + override fun remove() = Unit + } + every { watcher.close() } answers { unblock.countDown() } + + val devWorkspaceWatcher = newWatcher(scope) { _, _ -> watcher } + devWorkspaceWatcher.start("1") + + val deadline = System.currentTimeMillis() + 5_000 + while (!enteredHasNext.get()) { + check(System.currentTimeMillis() < deadline) { "watch loop never entered hasNext()" } + Thread.sleep(10) + } + + // when + val start = System.nanoTime() + devWorkspaceWatcher.stop() + val elapsedNanos = System.nanoTime() - start + + // then: close() unblocked the blocked iteration without a long delay + assertThat(elapsedNanos).isLessThan(1_000_000_000L) + + val unblockDeadline = System.currentTimeMillis() + 5_000 + while (!unblocked.get()) { + check(System.currentTimeMillis() < unblockDeadline) { "watch loop never unblocked after close()" } + Thread.sleep(10) + } + Thread.sleep(100) + } + + private fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition()) { + check(System.currentTimeMillis() < deadline) { + "Timed out after ${timeoutMs} ms waiting for condition" + } + Thread.sleep(20) + } + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt index 49e093d2..0aa98cbe 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/WorkspaceEditorInfoProviderTest.kt @@ -18,6 +18,7 @@ class WorkspaceEditorInfoProviderTest { @Test fun `#isJetBrainsEditor returns true for template with idea-server volume`() { + // given val dw = DevWorkspace( DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), DevWorkspaceSpec(started = true), @@ -31,11 +32,15 @@ class WorkspaceEditorInfoProviderTest { ) ) ) - assertThat(WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap)).isTrue() + // when + val isJetBrainsEditor = WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap) + // then + assertThat(isJetBrainsEditor).isTrue() } @Test fun `#isJetBrainsEditor returns false for template without idea-server volume`() { + // given val dw = DevWorkspace( DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), DevWorkspaceSpec(started = true), @@ -49,11 +54,15 @@ class WorkspaceEditorInfoProviderTest { ) ) ) - assertThat(WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap)).isFalse() + // when + val isJetBrainsEditor = WorkspaceEditorInfoProvider.isJetBrainsEditor(dw, templateMap) + // then + assertThat(isJetBrainsEditor).isFalse() } @Test fun `#create resolves template-based JetBrains editor to JETBRAINS`() { + // when val dw = DevWorkspace( DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), DevWorkspaceSpec(started = true), @@ -67,150 +76,373 @@ class WorkspaceEditorInfoProviderTest { ) ) ) + // when val info = WorkspaceEditorInfoProvider.create(dw, templateMap) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) assertThat(info.tooltip).isEqualTo("JetBrains") } @Test fun `#create maps webstorm editor to WEBSTORM`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-webstorm-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.WEBSTORM) assertThat(info.tooltip).isEqualTo("JetBrains WebStorm (desktop)") } @Test fun `#create maps pycharm editor to PYCHARM`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-pycharm-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.PYCHARM) assertThat(info.tooltip).isEqualTo("PyCharm") } @Test fun `#create maps unknown che-server editor to JETBRAINS`() { + // given val dw = createDevWorkspaceWithEditor("che-foo-server") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) assertThat(info.tooltip).isEqualTo("JetBrains") } @Test fun `#create maps vscode editor to VSCODE`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-code/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.VSCODE) assertThat(info.tooltip).isEqualTo("VS Code - Open Source") } @Test fun `#create maps intellij editor to INTELLIJ_IDEA`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-idea-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) assertThat(info.tooltip).isEqualTo("IntelliJ IDEA Ultimate (desktop)") } @Test fun `#create maps clion editor to CLION`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-clion-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.CLION) assertThat(info.tooltip).isEqualTo("JetBrains CLion (desktop)") } @Test fun `#create maps goland editor to GOLAND`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-goland-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.GOLAND) assertThat(info.tooltip).isEqualTo("JetBrains GoLand (desktop)") } @Test fun `#create maps phpstorm editor to PHPSTORM`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-phpstorm-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.PHPSTORM) assertThat(info.tooltip).isEqualTo("JetBrains PhpStorm (desktop)") } @Test fun `#create maps rider editor to RIDER`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-rider-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.RIDER) assertThat(info.tooltip).isEqualTo("JetBrains Rider (desktop)") } @Test fun `#create maps rubymine editor to RUBYMINE`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-rubymine-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.RUBYMINE) assertThat(info.tooltip).isEqualTo("JetBrains RubyMine (desktop)") } @Test fun `#create maps chemuxer editor to CHEMUXER`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-chemuxer-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.CHEMUXER) assertThat(info.tooltip).isEqualTo("Chemuxer") } @Test fun `#create maps herdr editor to HERDR`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-herdr-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.HERDR) assertThat(info.tooltip).isEqualTo("Herdr") } @Test fun `#create maps kiro editor to KIRO`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-kiro-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.KIRO) assertThat(info.tooltip).isEqualTo("Kiro (desktop)") } @Test fun `#create maps web terminal editor to WEB_TERMINAL`() { + // given val dw = createDevWorkspaceWithEditor("eclipse/che-web-terminal-server/latest") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.WEB_TERMINAL) assertThat(info.tooltip).isEqualTo("Web Terminal") } @Test fun `#create returns UNKNOWN for empty annotation`() { + // given val dw = DevWorkspace( DevWorkspaceObjectMeta(name = "test", namespace = "ns", uid = "uid1", emptyMap(), emptyMap()), DevWorkspaceSpec(started = true), DevWorkspaceStatus(phase = "Running") ) + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) assertThat(info.tooltip).isEqualTo("Unknown Editor") } @Test fun `#create returns UNKNOWN with fallback segment for partial path`() { + // given val dw = createDevWorkspaceWithEditor("some/partial") + // when val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) assertThat(info.tooltip).isEqualTo("partial") } - private fun createDevWorkspaceWithEditor(cheEditor: String): DevWorkspace { + @Test + fun `#create prefers che-editor annotation over idea-server template`() { + // given + val dw = createDevWorkspaceWithEditor("eclipse/che-code/latest") + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "test", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when, then + assertThat(WorkspaceEditorInfoProvider.create(dw, templateMap).kind).isEqualTo(WorkspaceEditorKind.VSCODE) + } + + @Test + fun `#create resolves JetBrains for che-editor-template annotation with idea-server template`() { + // given + val dw = DevWorkspace( + DevWorkspaceObjectMeta( + name = "test-workspace", + namespace = "test-namespace", + uid = "test-uid", + annotations = mapOf("che.eclipse.org/che-editor-template" to "jetbrains-template"), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "jetbrains-template", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when, then + assertThat(WorkspaceEditorInfoProvider.create(dw, templateMap).kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + } + + @Test + fun `#create resolves che-editor-template annotation over che-editor annotation`() { + // given + val dw = DevWorkspace( + DevWorkspaceObjectMeta( + name = "test-workspace", + namespace = "test-namespace", + uid = "test-uid", + annotations = mapOf( + "che.eclipse.org/che-editor-template" to "jetbrains-template", + "che.eclipse.org/che-editor" to "eclipse/che-code/latest" + ), + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "jetbrains-template", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when, then + assertThat(WorkspaceEditorInfoProvider.create(dw, templateMap).kind).isEqualTo(WorkspaceEditorKind.JETBRAINS) + } + + @Test + fun `#create finds che editor id in first segment for 2-part path`() { + val dw = createDevWorkspaceWithEditor("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 matches che-* prefix for 2-part path without -server suffix`() { + // given + val dw = createDevWorkspaceWithEditor("eclipse/che-idea") + // when + val info = WorkspaceEditorInfoProvider.create(dw, emptyMap()) + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.INTELLIJ_IDEA) + assertThat(info.tooltip).isEqualTo("IntelliJ IDEA Ultimate (desktop)") + } + + @Test + fun `#create template annotation present but template missing does not fall back to idea-server scan`() { + // given + val dw = createDevWorkspaceWithEditor( + "eclipse/che-code/latest", + mapOf("che.eclipse.org/che-editor-template" to "missing-template") + ) + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "other", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when + val info = WorkspaceEditorInfoProvider.create(dw, templateMap) + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.VSCODE) + } + + @Test + fun `#create template annotation found without idea-server defers to che editor`() { + // given + val dw = createDevWorkspaceWithEditor( + "eclipse/che-code/latest", + mapOf("che.eclipse.org/che-editor-template" to "my-template") + ) + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "my-template", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "vscode-server")))) + ), + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "other", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when + val info = WorkspaceEditorInfoProvider.create(dw, templateMap) + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.VSCODE) + } + + @Test + fun `#create template annotation without che editor does not classify as JetBrains via scan`() { + // given + val dw = createDevWorkspaceWithoutEditor( + mapOf("che.eclipse.org/che-editor-template" to "missing-template") + ) + val templateMap = mapOf( + "test-uid" to listOf( + DevWorkspaceTemplate( + metadata = DevWorkspaceTemplateMetadata(name = "other", namespace = "ns", pluginRegistryUrl = null, ownerRefencesUids = listOf("test-uid")), + spec = DevWorkspaceTemplateSpec(components = listOf(mapOf("volume" to mapOf("name" to "idea-server")))) + ) + ) + ) + // when + val info = WorkspaceEditorInfoProvider.create(dw, templateMap) + // then + assertThat(info.kind).isEqualTo(WorkspaceEditorKind.UNKNOWN) + } + + private fun createDevWorkspaceWithoutEditor(annotations: Map): DevWorkspace { + return DevWorkspace( + DevWorkspaceObjectMeta( + name = "test-workspace", + namespace = "test-namespace", + uid = "test-uid", + annotations = annotations, + labels = emptyMap() + ), + DevWorkspaceSpec(started = true), + DevWorkspaceStatus(phase = "Running") + ) + } + + private fun createDevWorkspaceWithEditor( + cheEditor: String, + additionalAnnotations: Map = emptyMap() + ): DevWorkspace { return DevWorkspace( DevWorkspaceObjectMeta( name = "test-workspace", namespace = "test-namespace", uid = "test-uid", - annotations = mapOf("che.eclipse.org/che-editor" to cheEditor), + annotations = mapOf("che.eclipse.org/che-editor" to cheEditor) + additionalAnnotations, labels = emptyMap() ), DevWorkspaceSpec(started = true), diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt index 0b2a34f0..9d6025c1 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt @@ -14,6 +14,7 @@ package com.redhat.devtools.gateway.server import com.redhat.devtools.gateway.util.ExponentialBackoff import io.mockk.* import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -303,4 +304,27 @@ class RemoteIDEServerReadinessTest { coVerify(atLeast = 1) { isReady(any()) } Unit } + + @Test + fun `#waitFor elapsed time includes probe duration not just backoff delays`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } coAnswers { + delay(200) + true + } + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + backoff = ExponentialBackoff(initialMillis = 100), + ) + + val start = System.currentTimeMillis() + val result = readiness.waitFor(isReadyState = true, timeout = 5) + val elapsed = System.currentTimeMillis() - start + + assertThat(result).isTrue + assertThat(elapsed).isGreaterThanOrEqualTo(180L) + Unit + } }