From 2a0957622e7a0d6d51fe97e18a0c0dd80aa6b7b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 23:53:12 +0200 Subject: [PATCH 1/5] Changelog update - `v0.8.0` (#229) Current pull request contains patched `CHANGELOG.md` file for the `v0.8.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235d295..7c37f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.0 - 2025-12-03 + ### Added - application name can now be displayed as the main title page instead of the URL From aa90d5761b94686177ef8f7fc89ee247b6c62693 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:19:09 +0200 Subject: [PATCH 2/5] chore: bump org.jetbrains.intellij:plugin-repository-rest-client from 2.0.49 to 2.0.50 (#230) Bumps [org.jetbrains.intellij:plugin-repository-rest-client](https://github.com/JetBrains/plugin-repository-rest-client) from 2.0.49 to 2.0.50.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij:plugin-repository-rest-client&package-manager=gradle&previous-version=2.0.49&new-version=2.0.50)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a518f8f..99fbba5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coroutines = "1.10.2" serialization = "1.8.1" okhttp = "4.12.0" dependency-license-report = "3.0.1" -marketplace-client = "2.0.49" +marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" From 874e8cc7494b0647783a8bb73ebdaf8bfa79116f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:19:40 +0200 Subject: [PATCH 3/5] chore: bump io.mockk:mockk from 1.14.6 to 1.14.7 (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.6 to 1.14.7.
Release notes

Sourced from io.mockk:mockk's releases.

v1.14.7

What's Changed

New Contributors

Full Changelog: https://github.com/mockk/mockk/compare/1.14.6...1.14.7

Commits
  • 3b99349 Version bump
  • d0e14bb Merge pull request #1455 from mockk/copilot/remove-transitive-junit-dependency
  • 9372ca6 Merge pull request #1464 from mockk/copilot/fix-stackoverflow-error-mockk
  • 73736a6 Address code review feedback for parseParamTypes
  • 6866dd0 Merge pull request #1454 from nishatoma/add-strict-mocking-system-property
  • ea99f88 Merge pull request #1456 from mockk/copilot/fix-mockk-compatibility-issue
  • b7b72de Merge pull request #1457 from mockk/copilot/fix-inaccessibleobjectexception
  • 08d1d1d Address comments
  • 7681de2 Merge pull request #1465 from TWiStErRob/patch-2
  • 54e6154 Fix configuration option example for restricted classes
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.mockk:mockk&package-manager=gradle&previous-version=1.14.6&new-version=1.14.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99fbba5..253d2c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ retrofit = "3.0.0" changelog = "2.5.0" gettext = "0.7.0" plugin-structure = "3.320" -mockk = "1.14.6" +mockk = "1.14.7" detekt = "1.23.8" bouncycastle = "1.83" From a2c028e9afefaeac380ddd365c52503f59961ff0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 10 Dec 2025 23:29:40 +0200 Subject: [PATCH 4/5] fix: simplify URI handling when the same deployment URL is already opened (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netflix reported that only seems to reproduce on Linux (we've only tested Ubuntu so far). I can’t reproduce it on macOS. First, here’s some context: 1. Polling workspaces: Coder Toolbox polls the deployment every 5 seconds for workspace updates. These updates (new workspaces, deletions,status changes) are stored in a cached “environments” list (an oversimplified explanation). When a URI is executed, we reset the content of the list and run the login sequence, which re-initializes the HTTP poller and CLI using the new deployment URL and token. A new polling loop then begins populating the environments list again. 2. Cache monitoring: Toolbox watches this cached list for changes—especially status changes, which determine when an SSH connection can be established. In Netflix’s case, they launched Toolbox, created a workspace from the Dashboard, and the poller added it to the environments list. When the workspace switched from starting to ready, they used a URI to connect to it. The URI reset the list, then the poller repopulated it. But because the list had the same IDs (but new object references), Toolbox didn’t detect any changes. As a result, it never triggered the SSH connection. This issue only reproduces on Linux, but it might explain some of the sporadic macOS failures Atif mentioned in the past. I need to dig deeper into the Toolbox bytecode to determine whether this is a Toolbox bug, but it does seem like Toolbox wasn’t designed to switch cleanly between multiple deployments and/or users. The current Coder plugin behavior—always performing a full login sequence on every URI—is also ...sub-optimal. It only really makes sense in these scenarios: 1. Toolbox started with deployment A, but the URI targets deployment B. 2. Toolbox started with deployment A/user X, but the URI targets deployment A/user Y. But this design is inefficient for the most common case: connecting via URI to a workspace on the same deployment and same user. While working on the fix, I realized that scenario (2) is not realistic. On the same host machine, why would multiple users log into the same deployment via Toolbox? The whole fix revolves around the idea of just recreating the http client and updating the CLI with the new token instead of going through the full authentication steps when the URI deployment URL is the same as the currently opened URL The fix focuses on simply recreating the HTTP client and updating the CLI token when the URI URL matches the existing deployment URL, instead of running a full login. This PR splits responsibilities more cleanly: - CoderProtocolHandler now only finds the workspace and agent and handles IDE installation and launch. - the logic for creating a new HTTP client, updating the CLI, cleaning up old resources (polling loop, environment cache), and handling deployment URL changes is separated out. The benefits would be: - shared logic for cleanup and re-initialization, with less coupling and clearer, more maintainable code. - a clean way to check whether the URI’s deployment URL matches the current one and react appropriately when they differ. --- CHANGELOG.md | 8 + gradle.properties | 2 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 12 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 156 ++++++++++++++---- .../toolbox/util/CoderProtocolHandler.kt | 129 ++------------- .../toolbox/views/CoderCliSetupWizardPage.kt | 6 +- .../com/coder/toolbox/views/CoderPage.kt | 2 + .../com/coder/toolbox/views/ConnectStep.kt | 20 ++- .../toolbox/util/CoderProtocolHandlerTest.kt | 10 +- 9 files changed, 182 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c37f46..62cf837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Changed + +- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience + +### Fixed + +- URI handling on Linux can now launch IDEs on newly started workspaces + ## 0.8.0 - 2025-12-03 ### Added diff --git a/gradle.properties b/gradle.properties index 60ed663..d9ce8bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.0 +version=0.8.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index a5790c3..5cf160d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -276,10 +276,8 @@ class CoderRemoteEnvironment( private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status - context.cs.launch(CoroutineName("Workspace Status Updater")) { - state.update { - environmentStatus.toRemoteEnvironmentState(context) - } + state.update { + environmentStatus.toRemoteEnvironmentState(context) } context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } @@ -312,10 +310,8 @@ class CoderRemoteEnvironment( */ fun startSshConnection(): Boolean { if (environmentStatus.ready() && !isConnected.value) { - context.cs.launch(CoroutineName("SSH Connection Trigger")) { - connectionRequest.update { - true - } + connectionRequest.update { + true } return true } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 217d4b1..98cc1ab 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,11 +2,20 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.TOKEN +import com.coder.toolbox.util.URL +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.token +import com.coder.toolbox.util.url +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -37,6 +46,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -44,6 +55,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory private val POLL_INTERVAL = 5.seconds +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -61,11 +73,13 @@ class CoderRemoteProvider( // The REST client, if we are signed in private var client: CoderRestClient? = null + private var cli: CoderCLIManager? = null // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private val isHandlingUri: AtomicBoolean = AtomicBoolean(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { client?.let { restClient -> @@ -82,7 +96,7 @@ class CoderRemoteProvider( providerVisible = false ) ) - private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + private val linkHandler = CoderProtocolHandler(context) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( @@ -254,6 +268,17 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { + softClose() + client = null + cli = null + lastEnvironments.clear() + environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") + } + + private fun softClose() { pollJob?.let { it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") @@ -262,12 +287,6 @@ class CoderRemoteProvider( it.close() context.logger.info("REST API client closed and resources released") } - client = null - lastEnvironments.clear() - environments.value = LoadableState.Value(emptyList()) - isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() - context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -331,27 +350,49 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - linkHandler.handle( - uri, - shouldDoAutoSetup() - ) { restClient, cli -> - context.logger.info("Stopping workspace polling and de-initializing resources") - close() - isInitialized.update { - false + 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 + } + isHandlingUri.set(true) + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + coderHeaderPage.isBusy.update { true } + context.logger.info("Handling $uri...") + val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return + val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + if (sameUrl(newUrl, client?.url)) { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) + } + } + } else { + CoderCliSetupContext.apply { + url = newUrl + token = newToken } - context.logger.info("Starting initialization with the new settings") - this@CoderRemoteProvider.client = restClient - if (context.settingsStore.useAppNameAsTitle) { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) - } else { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() } - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") - isInitialized.waitForTrue() } + // force the poll loop to run + triggerProviderVisible.send(true) + // wait for environments to be populated + isInitialized.waitForTrue() + + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -363,7 +404,63 @@ class CoderRemoteProvider( textError ?: "" ) context.envPageManager.showPluginEnvironmentsPage() + } finally { + coderHeaderPage.isBusy.update { false } + isHandlingUri.set(false) + firstRun = false + } + } + + 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 + } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + 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 fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize() + + private suspend fun refreshSession(url: URL, token: String): Pair { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val newRestClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val newCli = CoderCLIManager(context, url).apply { + login(token) + } + this.client = newRestClient + this.cli = newCli + pollJob = poll(newRestClient, newCli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + return newRestClient to newCli + } + + private suspend fun askUrl(): String? { + context.popupPluginMainPage() + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) } /** @@ -373,6 +470,9 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { + if (isHandlingUri.get()) { + return null + } // Show the setup page if we have not configured the client yet. if (client == null) { // When coming back to the application, initializeSession immediately. @@ -420,6 +520,7 @@ class CoderRemoteProvider( private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. + close() context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") @@ -428,10 +529,7 @@ class CoderRemoteProvider( context.logger.info("Deployment URL was stored and will be available for automatic connection") } this.client = client - pollJob?.let { - it.cancel() - context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") - } + this.cli = cli environments.showLoadingMessage() if (context.settingsStore.useAppNameAsTitle) { coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 113ab9f..ae6d13a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -7,26 +7,16 @@ 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.coder.toolbox.util.WebUrlValidationResult.Invalid -import com.coder.toolbox.views.CoderCliSetupWizardPage -import com.coder.toolbox.views.CoderSettingsPage -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState -import com.coder.toolbox.views.state.WizardStep -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.URI +import java.net.URL import java.util.UUID import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -36,10 +26,6 @@ private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, - private val dialogUi: DialogUi, - private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, - private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -51,40 +37,15 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - uri: URI, - shouldWaitForAutoLogin: Boolean, - reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + params: Map, + url: URL, + restClient: CoderRestClient, + cli: CoderCLIManager ) { - 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 - } - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } - - context.logger.info("Handling $uri...") - val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - - suspend fun onConnect( - restClient: CoderRestClient, - cli: CoderCLIManager - ) { - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) - if (workspace == null) { - context.envPageManager.showPluginEnvironmentsPage() - return - } - reInitialize(restClient, cli) - context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return + val workspace = restClient.workspaces().matchName(workspaceName, url) + if (workspace != null) { + if (!prepareWorkspace(workspace, restClient, cli, url)) 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 // we also need to retrieve the workspace again to have the latest resources (ex: agent) @@ -105,55 +66,8 @@ open class CoderProtocolHandler( if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { launchIde(environmentId, productCode, buildNumber, projectFolder) } - } - CoderCliSetupContext.apply { - url = deploymentURL.toURL() - CoderCliSetupContext.token = token } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - - // If Toolbox is already opened and URI is executed the setup page - // from below is never called. I tried a couple of things, including - // yielding the coroutine - but it seems to be of no help. What works - // delaying the coroutine for 66 - to 100 milliseconds, these numbers - // were determined by trial and error. - // The only explanation that I have is that inspecting the TBX bytecode it seems the - // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events - // and a drop oldest strategy. For some reason it seems that the UI collector - // is not yet active, causing the event to be lost unless we wait > 66 ms. - // I think this delay ensures the collector is ready before processEvent() is called. - delay(100.milliseconds) - context.ui.showUiPage( - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, true, - jumpToMainPageOnError = true, - onConnect = ::onConnect - ) - ) - } - - 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 - } - val validationResult = deploymentURL.validateStrictWebUrl() - if (validationResult is Invalid) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") - 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 resolveWorkspaceName(params: Map): String? { @@ -165,7 +79,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { context.logAndShowError( @@ -181,15 +95,14 @@ open class CoderProtocolHandler( workspace: Workspace, restClient: CoderRestClient, cli: CoderCLIManager, - workspaceName: String, - deploymentURL: String + url: URL ): 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" + "${workspace.name} from $url could not be ready on time" ) return false } @@ -199,7 +112,7 @@ open class CoderProtocolHandler( if (settings.disableAutostart) { context.logAndShowWarning( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL is not running and autostart is disabled" + "${workspace.name} from $url is not running and autostart is disabled" ) return false } @@ -213,7 +126,7 @@ open class CoderProtocolHandler( } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started", + "${workspace.name} from $url could not be started", e ) return false @@ -222,7 +135,7 @@ open class CoderProtocolHandler( if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started on time", + "${workspace.name} from $url could not be started on time", ) return false } @@ -231,7 +144,7 @@ open class CoderProtocolHandler( WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to connect to $workspaceName from $deploymentURL" + "Unable to connect to ${workspace.name} from $url" ) return false } @@ -433,19 +346,9 @@ open class CoderProtocolHandler( return false } } - - private suspend fun askUrl(): String? { - context.popupPluginMainPage() - return dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) - } } 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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index eca1179..2c74024 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -18,6 +18,7 @@ class CoderCliSetupWizardPage( visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, + connectSynchronously: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, @@ -33,9 +34,10 @@ class CoderCliSetupWizardPage( private val connectStep = ConnectStep( context, shouldAutoLogin = shouldAutoSetup, - jumpToMainPageOnError, + jumpToMainPageOnError = jumpToMainPageOnError, + connectSynchronously = connectSynchronously, visibilityState, - this::displaySteps, + refreshWizard = this::displaySteps, onConnect ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index a7ad70f..29a1e15 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -34,6 +34,8 @@ abstract class CoderPage( } } + override val isBusy: MutableStateFlow = MutableStateFlow(false) + /** * Return the icon, if showing one. * diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 247d2c4..3c1c8ef 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -13,10 +13,12 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -28,6 +30,7 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, + private val connectSynchronously: Boolean, visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, @@ -74,11 +77,15 @@ class ConnectStep( errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting val hostName = url.host + // Cancel previous job regardless of the new mode signInJob?.cancel() - signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { + + // 1. Extract the logic into a reusable suspend lambda + val connectionLogic: suspend CoroutineScope.() -> Unit = { try { context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( @@ -125,6 +132,17 @@ class ConnectStep( refreshWizard() } } + + // 2. Choose the execution strategy based on the flag + if (connectSynchronously) { + // Blocks the current thread until connectionLogic completes + runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { + connectionLogic() + } + } else { + // Runs asynchronously using the context's scope + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } } private fun logAndReportProgress(msg: String) { diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 1a84061..326fce0 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,11 +5,9 @@ import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.views.CoderSettingsPage 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.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings @@ -18,8 +16,6 @@ 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.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test @@ -58,11 +54,7 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context, - DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), - MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), - MutableStateFlow(false) + context ) @Test From 852b792b9da2e07a67084a0b9c5cf2b5f5ec02d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:26:27 +0200 Subject: [PATCH 5/5] Changelog update - `v0.8.1` (#233) Current pull request contains patched `CHANGELOG.md` file for the `v0.8.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62cf837..de79e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.1 - 2025-12-11 + ### Changed - streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience