From 111cd39951719a2a187d61f898c78aa0d343126e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 27 May 2025 00:01:01 +0300 Subject: [PATCH 01/16] chore: update the minimum API requirement Toolbox API is upgraded to 1.1.41749 which comes with new API additions and some deprecations. Kotlin stdlib was also increased to a newer patch version --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8546bd8..3cf5531 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -toolbox-plugin-api = "1.0.38881" -kotlin = "2.1.0" +toolbox-plugin-api = "1.1.41749" +kotlin = "2.1.10" coroutines = "1.10.1" serialization = "1.8.0" okhttp = "4.12.0" @@ -9,7 +9,7 @@ marketplace-client = "2.0.46" gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.0-1.0.29" +ksp = "2.1.10-1.0.31" retrofit = "2.11.0" changelog = "2.2.1" gettext = "0.7.0" From 2558180da460892a70403df6a7106809c8d77dd0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 27 May 2025 00:03:59 +0300 Subject: [PATCH 02/16] fix: use new env state API The CustomRemoteEnvironmentState is deprecated, and replaced by a new class CustomRemoteEnvironmentStateV2 which now supports i18n state labels --- .../toolbox/models/WorkspaceAndAgentStatus.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 3c9ad5f..cc04dfe 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.core.ui.color.StateColor -import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState @@ -61,9 +61,9 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { - return CustomRemoteEnvironmentState( - label, + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), color = getStateColor(context), reachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. @@ -90,10 +90,10 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { else EnvironmentStateIcons.NoIcon } - fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentState { + fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { val existingState = toRemoteEnvironmentState(context) - return CustomRemoteEnvironmentState( - "SSHing", + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr("SSHing"), existingState.color, existingState.isReachable, EnvironmentStateIcons.Connecting From a90128140f2c82036833c3539f5230640d6a2634 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 27 May 2025 00:06:20 +0300 Subject: [PATCH 03/16] fix: use the new ssh disconnect callback Toolbox provides two callbacks, one before an SSH connection is established and another one which executes when the ssh connection is stopped. The latter was deprecated in the favor of a new callback that also provides hints on whether the user requested the disconnect. --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index e6118c3..64a0cb0 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -203,7 +203,7 @@ class CoderRemoteEnvironment( private fun File.doesNotExists(): Boolean = !this.exists() - override fun afterDisconnect() { + override fun afterDisconnect(isManual: Boolean) { context.logger.info("Stopping the network metrics poll job for $id") pollJob?.cancel() this.connectionRequest.update { false } From 0df5319ec323e222f8feb65e1e561f62f6f2a214 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 27 May 2025 00:12:48 +0300 Subject: [PATCH 04/16] fix: use the new delete callback API Toolbox provides a callback for scenarios that involve the env. deletion. This allows plugins to react and clean the internal state. With the new TBX API, the delete callback API is deprecated in the favor of a mutable state flow, a reactive approach that allows consumers to observe and react to state changes over time. --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 64a0cb0..0608817 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -27,6 +27,7 @@ import com.squareup.moshi.Moshi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -269,7 +270,7 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { context.cs.launch { try { client.removeWorkspace(workspace) From 5e19a7da569429613b6a6bfd9d47b5c8206caf00 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 28 May 2025 00:53:03 +0300 Subject: [PATCH 05/16] impl: finish support for URI handling The available API up to TBX 2.6.3 was buggy in terms of URI handling. It didn't allow plugins to programmatically install remote ides and launch them. The launch operation only worked when the IDE was already installed and a project was already opened with the IDE. TBX 2.6.3 adds a new API, _RemoteToolboxHelp_ which provides routines for listing the available IDEs on the remote, what is already installed and a command to install specific versions of the IDE. Additionally, there were fixes provided to the existing _ClientHelper_ which now launches the JBClient if a project was not specified. An additional quirk I've discovered is that if we provide a project, and that project was not already opened (present in the Projects tab) the IDE still won't open. And there is no API available to query the available projects. This commit uses the new API to: - query the installed ides - check if the provided ide is in the list of already installed IDEs. - if that's not the case we query the available list of IDEs and the available versions - if the provided ide and build no., is in the available list we will schedule it for install - if not, we select the latest available build number for the provided product code. - wait for the remote IDE to be installed - and then download and launch the JBClient with a project path if it was provided. --- CHANGELOG.md | 4 + .../com/coder/toolbox/CoderToolboxContext.kt | 5 +- .../coder/toolbox/CoderToolboxExtension.kt | 2 + .../toolbox/util/CoderProtocolHandler.kt | 85 +++++++++++++++++-- .../resources/localization/defaultMessages.po | 3 + .../coder/toolbox/cli/CoderCLIManagerTest.kt | 2 + .../coder/toolbox/sdk/CoderRestClientTest.kt | 2 + 7 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 893daa4..fa101fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- support for Toolbox 2.6.3 with improved URI handling + ## 0.2.3 - 2025-05-26 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 06e1496..7711104 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -7,6 +7,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -14,11 +15,13 @@ import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope import java.net.URL +@Suppress("UnstableApiUsage") data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, - val ideOrchestrator: ClientHelper, + val remoteIdeOrchestrator: RemoteToolsHelper, + val jbClientOrchestrator: ClientHelper, val desktop: LocalDesktopManager, val cs: CoroutineScope, val logger: Logger, diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 05424ae..5cfcd11 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -13,6 +13,7 @@ import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -31,6 +32,7 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index ad42d18..2591ea9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow @@ -18,10 +19,13 @@ import kotlinx.coroutines.time.withTimeout import java.net.HttpURLConnection import java.net.URI import java.net.URL +import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, @@ -175,15 +179,67 @@ open class CoderProtocolHandler( val buildNumber = params.ideBuildNumber() val projectFolder = params.projectFolder() if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + var selectedIde = "$productCode-$buildNumber" context.cs.launch { - val ideVersion = "$productCode-$buildNumber" - context.logger.info("installing $ideVersion on $environmentId") + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + val alreadyInstalled = installedIdes.firstOrNull { it.contains(buildNumber) } != null + if (alreadyInstalled) { + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + } else { + val availableVersions = + context.remoteIdeOrchestrator.getAvailableRemoteTools(environmentId, productCode) + if (availableVersions.isEmpty()) { + val error = IllegalArgumentException("$productCode is not available on $environmentId") + context.logger.error(error, "Error encountered while handling Coder URI") + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while handling Coder URI"), + context.i18n.pnotr("$productCode is not available on $environmentId"), + context.i18n.ptrl("OK") + ) + return@launch + } + + val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null + if (!matchingBuildNumber) { + selectedIde = availableVersions.maxOf { it } + val msg = + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + context.logger.info(msg) + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$productCode-$buildNumber not available"), + context.i18n.pnotr(msg), + context.i18n.ptrl("OK") + ) + } + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + } + } + val job = context.cs.launch { - context.ideOrchestrator.prepareClient(environmentId, ideVersion) + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) } job.join() - context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder) + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) } } } @@ -203,6 +259,25 @@ open class CoderProtocolHandler( } } + private suspend fun RemoteToolsHelper.waitForIdeToBeInstalled( + environmentId: String, + ideHint: String, + waitTime: Duration = 2.minutes + ): Boolean { + var isInstalled = false + try { + withTimeout(waitTime.toJavaDuration()) { + while (!isInstalled) { + delay(5.seconds) + isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index ceba2e9..73da796 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -137,4 +137,7 @@ msgid "Network Status" msgstr "" msgid "Create workspace" +msgstr "" + +msgid "Error encountered while handling Coder URI" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index a7c6f72..4603fda 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -34,6 +34,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -66,6 +67,7 @@ internal class CoderCLIManagerTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index c32e7b1..2727228 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -24,6 +24,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -102,6 +103,7 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(), mockk(), From 7f740488b746402dc9e102da259e22d5d117be74 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 28 May 2025 23:15:08 +0300 Subject: [PATCH 06/16] chore: simplify uri handling implementation (1) Refactored code around uri, token, workspace and rest client resolving by encapsulating code in clearly named methods. Rest client resolving was overly-complicated (code inherited from Gateway), with token being a mandatory parameter. Removed a lot of code that asked the token from the user if it was missing. Also, I decided to use a snackbar to show errors because of attached regression in TBX. A bottom simple dialog is more pleasing to the eye than a dialog in the middle of a page. https://youtrack.jetbrains.com/issue/TBX-14944/Pop-up-dialogs-are-only-displayed-on-the-main-envs-page --- .../com/coder/toolbox/CoderToolboxContext.kt | 11 ++ .../toolbox/util/CoderProtocolHandler.kt | 116 +++++++++--------- .../kotlin/com/coder/toolbox/util/Dialogs.kt | 32 ----- 3 files changed, 68 insertions(+), 91 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 7711104..6bd0c69 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -14,6 +14,7 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope import java.net.URL +import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( @@ -47,4 +48,14 @@ data class CoderToolboxContext( } return this.settingsStore.defaultURL.toURL() } + + suspend fun logAndShowError(title: String, error: String) { + logger.error(error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 2591ea9..a6e156a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -25,6 +25,8 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" + @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, @@ -46,35 +48,18 @@ open class CoderProtocolHandler( reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { context.popupPluginMainPage() + context.logger.info("Handling $uri...") val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario return } - val deploymentURL = params.url() ?: askUrl() - if (deploymentURL.isNullOrBlank()) { - context.logger.error("Query parameter \"$URL\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) - return - } - - val queryToken = params.token() - val restClient = try { - authenticate(deploymentURL, queryToken) - } catch (ex: Exception) { - context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") - context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex))) - return - } - + val deploymentURL = resolveDeploymentUrl(params) ?: return + val token = resolveToken(params) ?: return // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = params.workspace() - if (workspaceName.isNullOrBlank()) { - context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) - return - } + val workspaceName = resolveWorkspace(params) ?: return + val restClient = buildRestClient(deploymentURL, token) ?: return val workspaces = restClient.workspaces() val workspace = workspaces.firstOrNull { it.name == workspaceName } @@ -244,6 +229,56 @@ open class CoderProtocolHandler( } } + private suspend fun resolveDeploymentUrl(params: Map): String? { + val deploymentURL = params.url() ?: askUrl() + if (deploymentURL.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") + return null + } + return deploymentURL + } + + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null + } + return token + } + + private suspend fun resolveWorkspace(params: Map): String? { + val workspace = params.workspace() + if (workspace.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") + return null + } + return workspace + } + + private suspend fun buildRestClient(deploymentURL: String, token: String): CoderRestClient? { + try { + return authenticate(deploymentURL, token) + } catch (ex: Exception) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) + return null + } + } + + /** + * Returns an authenticated Coder CLI. + */ + private suspend fun authenticate(deploymentURL: String, token: String): CoderRestClient { + val client = CoderRestClient( + context, + deploymentURL.toURL(), + if (settings.requireTokenAuth) token else null, + PluginManager.pluginInfo.version + ) + client.authenticate() + return client + } + private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { var status = workspace.latestBuild.status try { @@ -285,43 +320,6 @@ open class CoderProtocolHandler( context.i18n.ptrl("Enter the full URL of your Coder deployment") ) } - - /** - * Return an authenticated Coder CLI, asking for the token. - * Throw MissingArgumentException if the user aborts. Any network or invalid - * token error may also be thrown. - */ - private suspend fun authenticate( - deploymentURL: String, - tryToken: String? - ): CoderRestClient { - val token = - if (settings.requireTokenAuth) { - // Try the provided token immediately on the first attempt. - if (!tryToken.isNullOrBlank()) { - tryToken - } else { - context.popupPluginMainPage() - // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken(deploymentURL.toURL()) - } - } else { - null - } - - if (settings.requireTokenAuth && token == null) { // User aborted. - throw MissingArgumentException("Token is required") - } - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.authenticate() - return client - } - } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index d3adabc..3678813 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,10 +1,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.browser.browse import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType -import java.net.URL /** * Dialog implementation for standalone Gateway. @@ -26,34 +24,4 @@ class DialogUi(private val context: CoderToolboxContext) { title, description, placeholder, TextType.General, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") ) } - - suspend fun askPassword( - title: LocalizableString, - description: LocalizableString, - placeholder: LocalizableString? = null, - ): String? { - return context.ui.showTextInputPopup( - title, description, placeholder, TextType.Password, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") - ) - } - - private suspend fun openUrl(url: URL) { - context.desktop.browse(url.toString()) { - context.ui.showErrorInfoPopup(it) - } - } - - /** - * Open a dialog for providing the token. - */ - suspend fun askToken( - url: URL, - ): String? { - openUrl(url.withPath("/login?redirect=%2Fcli-auth")) - return askPassword( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr("Please paste the session token from the web-page"), - placeholder = context.i18n.pnotr("") - ) - } } From bb4b6438e5d34dd486ec49678433fb957daab2aa Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 28 May 2025 23:58:14 +0300 Subject: [PATCH 07/16] chore: simplify uri handling implementation (2) Refactored code around workspace and agent resolving. --- .../com/coder/toolbox/CoderToolboxContext.kt | 20 ++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 6 +- .../toolbox/util/CoderProtocolHandler.kt | 271 ++++++++++-------- 3 files changed, 177 insertions(+), 120 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 6bd0c69..b7305c0 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -58,4 +58,24 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + suspend fun logAndShowError(title: String, error: String, exception: Exception) { + logger.error(exception, error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(warning), + i18n.ptrl("OK") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 6785675..9f619bc 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -192,12 +192,12 @@ open class CoderRestClient( } /** - * Maps the list of workspaces to the associated agents. + * Maps the available workspaces to the associated agents. */ - suspend fun groupByAgents(workspaces: List): Set> { + suspend fun workspacesByAgents(): Set> { // It is possible for there to be resources with duplicate names so we // need to use a set. - return workspaces.flatMap { ws -> + return workspaces().flatMap { ws -> when (ws.latestBuild.status) { WorkspaceStatus.RUNNING -> ws.latestBuild.resources else -> resources(ws) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index a6e156a..06ae126 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -57,85 +57,13 @@ open class CoderProtocolHandler( val deploymentURL = resolveDeploymentUrl(params) ?: return val token = resolveToken(params) ?: return - // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = resolveWorkspace(params) ?: return + val workspaceName = resolveWorkspaceName(params) ?: return val restClient = buildRestClient(deploymentURL, token) ?: return + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return + val agent = resolveAgent(params, workspace) ?: return - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } - if (workspace == null) { - context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) - return - } - - when (workspace.latestBuild.status) { - WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be ready on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) - return - } - - WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, - WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { - if (settings.disableAutostart) { - context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.") - context.showInfoPopup( - context.i18n.pnotr("$workspaceName is not running"), - context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."), - context.i18n.ptrl("OK") - ) - return - } - - try { - restClient.startWorkspace(workspace) - } catch (e: Exception) { - context.logger.error( - e, - "$workspaceName from $deploymentURL could not be started while handling URI" - ) - context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName")) - return - } - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be started on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) - return - } - } - - WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { - context.logger.error("Unable to connect to $workspaceName from $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) - return - } - - WorkspaceStatus.RUNNING -> Unit // All is well - } - - // TODO: Show a dropdown and ask for an agent if missing. - val agent: WorkspaceAgent - try { - agent = getMatchingAgent(params, workspace) - } catch (e: IllegalArgumentException) { - context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") - context.showErrorPopup( - MissingArgumentException( - "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", - e - ) - ) - return - } - val status = WorkspaceAndAgentStatus.from(workspace, agent) - - if (!status.ready()) { - context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")) - return - } + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + if (!ensureAgentIsReady(workspace, agent)) return val cli = ensureCLI( context, @@ -150,7 +78,7 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.groupByAgents(workspaces)) + cli.configSsh(restClient.workspacesByAgents()) if (shouldWaitForAutoLogin) { isInitialized.waitForTrue() @@ -247,7 +175,7 @@ open class CoderProtocolHandler( return token } - private suspend fun resolveWorkspace(params: Map): String? { + private suspend fun resolveWorkspaceName(params: Map): String? { val workspace = params.workspace() if (workspace.isNullOrBlank()) { context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") @@ -279,6 +207,153 @@ open class CoderProtocolHandler( return client } + private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + val workspace = this.firstOrNull { it.name == workspaceName } + if (workspace == null) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "There is no workspace with name $workspaceName on $deploymentURL" + ) + return null + } + return workspace + } + + private suspend fun prepareWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + workspaceName: String, + deploymentURL: String + ): Boolean { + when (workspace.latestBuild.status) { + WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be ready on time" + ) + return false + } + + WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, + WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { + if (settings.disableAutostart) { + context.logAndShowWarning( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL is not running and autostart is disabled" + ) + return false + } + + try { + restClient.startWorkspace(workspace) + } catch (e: Exception) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started", + e + ) + return false + } + + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started on time", + ) + return false + } + } + + WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to connect to $workspaceName from $deploymentURL" + ) + return false + } + + WorkspaceStatus.RUNNING -> return true // All is well + } + return true + } + + private suspend fun resolveAgent( + params: Map, + workspace: Workspace + ): WorkspaceAgent? { + try { + return getMatchingAgent(params, workspace) + } catch (e: IllegalArgumentException) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e + ) + return null + } + } + + /** + * Return the agent matching the provided agent ID or name in the parameters. + * + * @throws [IllegalArgumentException] + */ + private suspend fun getMatchingAgent( + parameters: Map, + workspace: Workspace, + ): WorkspaceAgent? { + val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + if (agents.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") + return null + } + + // If the agent is missing and the workspace has only one, use that. + // Prefer the ID over the name if both are set. + val agent = + if (!parameters.agentID().isNullOrBlank()) { + agents.firstOrNull { it.id.toString() == parameters.agentID() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } + + if (agent == null) { + if (!parameters.agentID().isNullOrBlank()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"" + ) + return null + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent" + ) + return null + } + } + return agent + } + + private suspend fun ensureAgentIsReady( + workspace: Workspace, + agent: WorkspaceAgent + ): Boolean { + val status = WorkspaceAndAgentStatus.from(workspace, agent) + + if (!status.ready()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Agent ${agent.name} for workspace ${workspace.name} is not ready" + ) + return false + } + return true + } + private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { var status = workspace.latestBuild.status try { @@ -346,44 +421,6 @@ internal fun resolveRedirects(url: URL): URL { throw Exception("Too many redirects") } -/** - * Return the agent matching the provided agent ID or name in the parameters. - * - * @throws [IllegalArgumentException] - */ -internal fun getMatchingAgent( - parameters: Map, - workspace: Workspace, -): WorkspaceAgent { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } - if (agents.isEmpty()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents") - } - - // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } - - if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else { - throw MissingArgumentException( - "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", - ) - } - } - - return agent -} - private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { popupPluginMainPage() this.ui.showErrorInfoPopup(error) From a6c2eb6cf4267b57a73db6923980c85348d244ab Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 29 May 2025 00:09:34 +0300 Subject: [PATCH 08/16] chore: simplify uri handling implementation (3) Refactored code around cli initialization. --- .../toolbox/util/CoderProtocolHandler.kt | 179 ++++++++++-------- 1 file changed, 101 insertions(+), 78 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 06ae126..3ecef65 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -65,20 +65,7 @@ open class CoderProtocolHandler( if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return if (!ensureAgentIsReady(workspace, agent)) return - val cli = ensureCLI( - context, - deploymentURL.toURL(), - restClient.buildInfo().version - ) - - // We only need to log in if we are using token-based auth. - if (restClient.token != null) { - context.logger.info("Authenticating Coder CLI...") - cli.login(restClient.token) - } - - context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.workspacesByAgents()) + val cli = configureCli(deploymentURL, restClient) if (shouldWaitForAutoLogin) { isInitialized.waitForTrue() @@ -86,74 +73,14 @@ open class CoderProtocolHandler( reInitialize(restClient, cli) val environmentId = "${workspace.name}.${agent.name}" - context.popupPluginMainPage() - context.envPageManager.showEnvironmentPage(environmentId, false) + context.showEnvironmentPage(environmentId) + val productCode = params.ideProductCode() val buildNumber = params.ideBuildNumber() val projectFolder = params.projectFolder() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - var selectedIde = "$productCode-$buildNumber" - context.cs.launch { - val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) - val alreadyInstalled = installedIdes.firstOrNull { it.contains(buildNumber) } != null - if (alreadyInstalled) { - context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") - } else { - val availableVersions = - context.remoteIdeOrchestrator.getAvailableRemoteTools(environmentId, productCode) - if (availableVersions.isEmpty()) { - val error = IllegalArgumentException("$productCode is not available on $environmentId") - context.logger.error(error, "Error encountered while handling Coder URI") - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while handling Coder URI"), - context.i18n.pnotr("$productCode is not available on $environmentId"), - context.i18n.ptrl("OK") - ) - return@launch - } - - val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null - if (!matchingBuildNumber) { - selectedIde = availableVersions.maxOf { it } - val msg = - "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" - context.logger.info(msg) - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr("$productCode-$buildNumber not available"), - context.i18n.pnotr(msg), - context.i18n.ptrl("OK") - ) - } - // needed otherwise TBX will install it again - if (!installedIdes.contains(selectedIde)) { - context.logger.info("Installing $selectedIde on $environmentId...") - context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) - if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { - context.logger.info("Successfully installed $selectedIde on $environmentId...") - } else { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr("$selectedIde could not be installed"), - context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), - context.i18n.ptrl("OK") - ) - } - } else { - context.logger.info("$selectedIde is already present on $environmentId...") - } - } - - val job = context.cs.launch { - context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") - context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) - } - job.join() - context.logger.info("Launching $selectedIde on $environmentId") - context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) - } + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(productCode, buildNumber, environmentId, projectFolder) } } @@ -354,6 +281,97 @@ open class CoderProtocolHandler( return true } + private suspend fun configureCli( + deploymentURL: String, + restClient: CoderRestClient + ): CoderCLIManager { + val cli = ensureCLI( + context, + deploymentURL.toURL(), + restClient.buildInfo().version + ) + + // We only need to log in if we are using token-based auth. + if (restClient.token != null) { + context.logger.info("Authenticating Coder CLI...") + cli.login(restClient.token) + } + + context.logger.info("Configuring Coder CLI...") + cli.configSsh(restClient.workspacesByAgents()) + return cli + } + + private fun launchIde( + productCode: String, + buildNumber: String, + environmentId: String, + projectFolder: String? + ) { + var selectedIde = "$productCode-$buildNumber" + context.cs.launch { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + val alreadyInstalled = installedIdes.firstOrNull { it.contains(buildNumber) } != null + if (alreadyInstalled) { + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + } else { + val availableVersions = + context.remoteIdeOrchestrator.getAvailableRemoteTools(environmentId, productCode) + if (availableVersions.isEmpty()) { + val error = IllegalArgumentException("$productCode is not available on $environmentId") + context.logger.error(error, "Error encountered while handling Coder URI") + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while handling Coder URI"), + context.i18n.pnotr("$productCode is not available on $environmentId"), + context.i18n.ptrl("OK") + ) + return@launch + } + + val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null + if (!matchingBuildNumber) { + selectedIde = availableVersions.maxOf { it } + val msg = + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + context.logger.info(msg) + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$productCode-$buildNumber not available"), + context.i18n.pnotr(msg), + context.i18n.ptrl("OK") + ) + } + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + } + } + + val job = context.cs.launch { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } + job.join() + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) + } + } + private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { var status = workspace.latestBuild.status try { @@ -440,4 +458,9 @@ private fun CoderToolboxContext.popupPluginMainPage() { this.envPageManager.showPluginEnvironmentsPage(true) } +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { + this.ui.showWindow() + this.envPageManager.showEnvironmentPage(envId, false) +} + class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) From 32b3911b583af7ec7aa804caf82e35e9c57be3ee Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 29 May 2025 00:42:59 +0300 Subject: [PATCH 09/16] chore: simplify uri handling implementation (4) Refactored code around remote ide installation, local jbclient install and launch --- .../com/coder/toolbox/CoderToolboxContext.kt | 10 ++ .../toolbox/util/CoderProtocolHandler.kt | 131 ++++++++++-------- 2 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index b7305c0..4291321 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -78,4 +78,14 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + suspend fun logAndShowInfo(title: String, info: String) { + logger.info(info) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(info), + i18n.ptrl("OK") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3ecef65..d3d35a1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -11,6 +11,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow @@ -80,7 +81,7 @@ open class CoderProtocolHandler( val projectFolder = params.projectFolder() if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - launchIde(productCode, buildNumber, environmentId, projectFolder) + launchIde(environmentId, productCode, buildNumber, projectFolder) } } @@ -303,73 +304,87 @@ open class CoderProtocolHandler( } private fun launchIde( + environmentId: String, productCode: String, buildNumber: String, - environmentId: String, projectFolder: String? ) { - var selectedIde = "$productCode-$buildNumber" context.cs.launch { - val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) - val alreadyInstalled = installedIdes.firstOrNull { it.contains(buildNumber) } != null - if (alreadyInstalled) { - context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") - } else { - val availableVersions = - context.remoteIdeOrchestrator.getAvailableRemoteTools(environmentId, productCode) - if (availableVersions.isEmpty()) { - val error = IllegalArgumentException("$productCode is not available on $environmentId") - context.logger.error(error, "Error encountered while handling Coder URI") - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while handling Coder URI"), - context.i18n.pnotr("$productCode is not available on $environmentId"), - context.i18n.ptrl("OK") - ) - return@launch - } + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + installJBClient(selectedIde, environmentId).join() + launchJBClient(selectedIde, environmentId, projectFolder) + } + } - val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null - if (!matchingBuildNumber) { - selectedIde = availableVersions.maxOf { it } - val msg = - "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" - context.logger.info(msg) - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr("$productCode-$buildNumber not available"), - context.i18n.pnotr(msg), - context.i18n.ptrl("OK") - ) - } + private suspend fun selectAndInstallRemoteIde( + productCode: String, + buildNumber: String, + environmentId: String + ): String? { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) - // needed otherwise TBX will install it again - if (!installedIdes.contains(selectedIde)) { - context.logger.info("Installing $selectedIde on $environmentId...") - context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) - if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { - context.logger.info("Successfully installed $selectedIde on $environmentId...") - } else { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr("$selectedIde could not be installed"), - context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), - context.i18n.ptrl("OK") - ) - } - } else { - context.logger.info("$selectedIde is already present on $environmentId...") - } - } + var selectedIde = "$productCode-$buildNumber" + if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde + } + + selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null - val job = context.cs.launch { - context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") - context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } - job.join() - context.logger.info("Launching $selectedIde on $environmentId") - context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + return selectedIde + } + } + + private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { + val availableVersions = context + .remoteIdeOrchestrator + .getAvailableRemoteTools(environmentId, productCode) + + if (availableVersions.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null + } + + val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null + if (!matchingBuildNumber) { + val selectedIde = availableVersions.maxOf { it } + context.logAndShowInfo( + "$productCode-$buildNumber not available", + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + ) + return selectedIde } + return null + } + + private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } + + private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) } private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { From 122db33e70ce6b9e349560b51c71899f838a4b23 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 29 May 2025 23:33:38 +0300 Subject: [PATCH 10/16] chore: simplify uri handling implementation (5) Removed unused code and refactored unit tests to take into account that some of the methods are no longer static, and that return type changed. --- .../toolbox/util/CoderProtocolHandler.kt | 44 +---- .../kotlin/com/coder/toolbox/util/LinkMap.kt | 1 - .../com/coder/toolbox/util/LinkHandlerTest.kt | 161 +++++++----------- 3 files changed, 66 insertions(+), 140 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index d3d35a1..a18bda1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,7 +9,6 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -17,9 +16,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.HttpURLConnection import java.net.URI -import java.net.URL import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -227,7 +224,7 @@ open class CoderProtocolHandler( * * @throws [IllegalArgumentException] */ - private suspend fun getMatchingAgent( + internal suspend fun getMatchingAgent( parameters: Map, workspace: Workspace, ): WorkspaceAgent? { @@ -238,7 +235,6 @@ open class CoderProtocolHandler( } // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. val agent = if (!parameters.agentID().isNullOrBlank()) { agents.firstOrNull { it.id.toString() == parameters.agentID() } @@ -430,44 +426,6 @@ open class CoderProtocolHandler( } } -/** - * Follow a URL's redirects to its final destination. - */ -internal fun resolveRedirects(url: URL): URL { - var location = url - val maxRedirects = 10 - for (i in 1..maxRedirects) { - val conn = location.openConnection() as HttpURLConnection - conn.instanceFollowRedirects = false - conn.connect() - val code = conn.responseCode - val nextLocation = conn.getHeaderField("Location") - conn.disconnect() - // Redirects are triggered by any code starting with 3 plus a - // location header. - if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { - return location - } - // Location headers might be relative. - location = URL(location, nextLocation) - } - throw Exception("Too many redirects") -} - -private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { - popupPluginMainPage() - this.ui.showErrorInfoPopup(error) -} - -private suspend fun CoderToolboxContext.showInfoPopup( - title: LocalizableString, - message: LocalizableString, - okLabel: LocalizableString -) { - popupPluginMainPage() - this.ui.showInfoPopup(title, message, okLabel) -} - private fun CoderToolboxContext.popupPluginMainPage() { this.ui.showWindow() this.envPageManager.showPluginEnvironmentsPage(true) diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 1135227..0a15db8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt index bb87151..f2742de 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt @@ -1,41 +1,49 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.DataGen -import com.sun.net.httpserver.HttpHandler -import com.sun.net.httpserver.HttpServer -import java.net.HttpURLConnection -import java.net.InetSocketAddress +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSecretsStore +import com.coder.toolbox.store.CoderSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals -import kotlin.test.assertFailsWith +import kotlin.test.assertNull internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } + private val context = CoderToolboxContext( + mockk(relaxed = true), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(relaxed = true), + CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), + mockk(), + mockk() + ) + + private val protocolHandler = CoderProtocolHandler( + context, + DialogUi(context), + MutableStateFlow(false) + ) private val agents = mapOf( @@ -49,7 +57,7 @@ internal class LinkHandlerTest { ) @Test - fun getMatchingAgent() { + fun tstgetMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = @@ -74,9 +82,10 @@ internal class LinkHandlerTest { "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ), ) - - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) + runBlocking { + tests.forEach { + assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -104,14 +113,10 @@ internal class LinkHandlerTest { "agent with ID", ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -126,15 +131,16 @@ internal class LinkHandlerTest { mapOf("agent" to null), mapOf("agent_id" to null), ) - - tests.forEach { - assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, - ) + runBlocking { + tests.forEach { + assertEquals( + UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + protocolHandler.getMatchingAgent( + it, + ws, + )?.id, + ) + } } } @@ -149,14 +155,10 @@ internal class LinkHandlerTest { "agent with ID" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -177,43 +179,10 @@ internal class LinkHandlerTest { "has no agents" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) - - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) - } - - @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) - - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) - - srv.stop(0) + } } } From 5bac965b264a769435d336a1c7443e4a6797de13 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 29 May 2025 23:34:45 +0300 Subject: [PATCH 11/16] chore: simplify uri handling implementation (6) Rename class to be in sync with the class it tests --- .../util/{LinkHandlerTest.kt => CoderProtocolHandlerTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/test/kotlin/com/coder/toolbox/util/{LinkHandlerTest.kt => CoderProtocolHandlerTest.kt} (99%) diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt similarity index 99% rename from src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt rename to src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index f2742de..2914eae 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -23,7 +23,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -internal class LinkHandlerTest { +internal class CoderProtocolHandlerTest { private val context = CoderToolboxContext( mockk(relaxed = true), mockk(), From e3c76f65efc8e6645f24c3cc8d514ea35b3581d9 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 29 May 2025 23:57:26 +0300 Subject: [PATCH 12/16] doc: update README with attention note `folder` URI param needs to be a path to an IDEA project that was already opened in the IDE. Or it should not be provided at all otherwise the remote IDE won't start. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2b749e3..2034504 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable experience, it’s recommended to ensure the workspace is running prior to initiating the connection. +> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` tab. +> If the path refers to a project that doesn't exist, the remote IDE won’t start or load it. + +> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a previously opened project or leave it empty. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that From 4044e0dde456ff50afc00adc17e7fbd5a14a4508 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 30 May 2025 00:59:48 +0300 Subject: [PATCH 13/16] fix: resolve agent only after workspace was resolved Switched the order of the steps, the agent and hist state is resolved only after the workspace was resolved and after it was in a running state. Otherwise URI handling during workspace startup could provide misleading errors related to agent not existing or not being ready. --- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index a18bda1..8537a5e 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -58,9 +58,11 @@ open class CoderProtocolHandler( val workspaceName = resolveWorkspaceName(params) ?: return val restClient = buildRestClient(deploymentURL, token) ?: return val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return - val agent = resolveAgent(params, workspace) ?: return - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + val agent = resolveAgent(params, workspace) ?: return if (!ensureAgentIsReady(workspace, agent)) return val cli = configureCli(deploymentURL, restClient) From 048ab312ca1f2fef0ab09356a4b06bb77562e7d1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 30 May 2025 01:48:45 +0300 Subject: [PATCH 14/16] fix: wait for plugin to fully initialize And only then start validating the parameters. Otherwise, we can end up in a situation where we ask TBX to show a snackbar while TBX is not yet visible/initialized. In that case the UI page doesn't show the snackbar --- .../com/coder/toolbox/util/CoderProtocolHandler.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 8537a5e..524a181 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -45,14 +45,19 @@ open class CoderProtocolHandler( shouldWaitForAutoLogin: Boolean, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - context.popupPluginMainPage() - context.logger.info("Handling $uri...") val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + + context.logger.info("Handling $uri...") + context.popupPluginMainPage() val deploymentURL = resolveDeploymentUrl(params) ?: return val token = resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return @@ -66,10 +71,6 @@ open class CoderProtocolHandler( if (!ensureAgentIsReady(workspace, agent)) return val cli = configureCli(deploymentURL, restClient) - - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } reInitialize(restClient, cli) val environmentId = "${workspace.name}.${agent.name}" From 823e2e74a827419b5bfe485413e6057e778e4133 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 30 May 2025 02:02:45 +0300 Subject: [PATCH 15/16] chore: remove redundant call to showWindow Toolbox already does that whe executing a URI while TBX is stopped. And during runtime it seems that snackbars have the ability to request the window to be visible. Only the rest of the dialogs need explicit request for the TBX window to be visible --- src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 524a181..af07548 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -57,7 +57,6 @@ open class CoderProtocolHandler( } context.logger.info("Handling $uri...") - context.popupPluginMainPage() val deploymentURL = resolveDeploymentUrl(params) ?: return val token = resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return From 63af2aecd5513ec8c6b5c7d8d830cf1cd672ad39 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 2 Jun 2025 20:43:51 +0300 Subject: [PATCH 16/16] chore: next version is 0.3.0 This version is only supporting TBX 2.6.3 and above so it is worth at least a minor increase. --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8433f61..a6129a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.3 +version=0.3.0 group=com.coder.toolbox name=coder-toolbox