From e53595b3736f954a25fdd6e401f518637fc4f30d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Jun 2025 23:04:06 +0300 Subject: [PATCH 1/4] fix: update&start outdated workspaces URI handling was not able to start workspaces that were stopped and also outdated. --- CHANGELOG.md | 4 ++++ .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4be5d..7fd0369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- outdated and stopped workspaces are now updated and started when handling URI + ## 0.3.0 - 2025-06-10 ### Added diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index af07548..60ec1c7 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -173,7 +173,11 @@ open class CoderProtocolHandler( } try { - restClient.startWorkspace(workspace) + if (workspace.outdated) { + restClient.updateWorkspace(workspace) + } else { + restClient.startWorkspace(workspace) + } } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, From 25e6c44d0b2bad70d04f67556407062ed3db1f70 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 11 Jun 2025 01:13:49 +0300 Subject: [PATCH 2/4] fix: mark the deployment as busy During URI handling we can wait for workspaces to start up. The existing implementation had no visual feedback that we are waiting/doing anything. With this PR the header bar shows a nice work in progress visual. Additionally, the deployment URL was not properly refreshed when switching between different urls via URI handling. This is also fixed by forcing TBX to render a blank page for a very short period of time and then going back to the main page. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 21 ++++++++++++++----- .../com/coder/toolbox/CoderToolboxContext.kt | 12 +++++++++++ .../toolbox/util/CoderProtocolHandler.kt | 21 +++++++++++++------ .../com/coder/toolbox/views/AuthWizardPage.kt | 2 +- .../com/coder/toolbox/views/CoderPage.kt | 3 +++ .../com/coder/toolbox/views/ConnectStep.kt | 2 +- 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 7aabdce..3a72a84 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -9,7 +9,6 @@ import com.coder.toolbox.util.DialogUi import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.AuthWizardPage -import com.coder.toolbox.views.CoderPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.state.AuthWizardState @@ -269,12 +268,25 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle(uri, shouldDoAutoLogin()) { restClient, cli -> + linkHandler.handle( + uri, shouldDoAutoLogin(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } + } + ) { restClient, cli -> // stop polling and de-initialize resources close() // start initialization with the new settings this@CoderRemoteProvider.client = restClient coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + environments.showLoadingMessage() pollJob = poll(restClient, cli) } @@ -332,7 +344,7 @@ class CoderRemoteProvider( private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" @@ -344,8 +356,7 @@ class CoderRemoteProvider( environments.showLoadingMessage() coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) - context.ui.showUiPage(CoderPage.emptyPage(context)) - goToEnvironmentsPage() + context.refreshMainPage() } private fun MutableStateFlow>>.showLoadingMessage() { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 4291321..18a79fc 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -3,6 +3,7 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.CoderPage import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -13,8 +14,10 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import java.net.URL import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds @Suppress("UnstableApiUsage") data class CoderToolboxContext( @@ -88,4 +91,13 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + /** + * Forces the title bar on the main page to be refreshed + */ + suspend fun refreshMainPage() { + ui.showUiPage(CoderPage.emptyPage(this)) + delay(10.milliseconds) + envPageManager.showPluginEnvironmentsPage() + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 60ec1c7..223974e 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -43,6 +43,8 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, + markAsBusy: () -> Unit, + unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -62,16 +64,22 @@ open class CoderProtocolHandler( val workspaceName = resolveWorkspaceName(params) ?: return val restClient = buildRestClient(deploymentURL, token) ?: return val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: 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) reInitialize(restClient, cli) + var agent: WorkspaceAgent + try { + markAsBusy() + context.refreshMainPage() + 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 + agent = resolveAgent(params, workspace) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + } finally { + unmarkAsBusy() + } val environmentId = "${workspace.name}.${agent.name}" context.showEnvironmentPage(environmentId) @@ -432,6 +440,7 @@ open class CoderProtocolHandler( } } + private fun CoderToolboxContext.popupPluginMainPage() { this.ui.showWindow() this.envPageManager.showPluginEnvironmentsPage(true) diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt index 06009d7..d7f0dbf 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt @@ -16,7 +16,7 @@ class AuthWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, initialAutoLogin: Boolean = false, - onConnect: ( + onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index ac94a36..43c6fa5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -6,6 +6,7 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.util.UUID @@ -39,6 +40,8 @@ abstract class CoderPage( SvgIcon(byteArrayOf(), type = IconType.Masked) } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + /** * Show an error as a popup on this page. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7875728..58e154e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -28,7 +28,7 @@ class ConnectStep( private val shouldAutoLogin: StateFlow, private val notify: (String, Throwable) -> Unit, private val refreshWizard: () -> Unit, - private val onConnect: ( + private val onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, From fd55a2cdcec536acfbd56dfe26d89c06261044cb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 11 Jun 2025 23:16:27 +0300 Subject: [PATCH 3/4] chore: document how TBX handles UI events --- .../kotlin/com/coder/toolbox/CoderToolboxContext.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 18a79fc..0bb4135 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -96,7 +96,20 @@ data class CoderToolboxContext( * Forces the title bar on the main page to be refreshed */ suspend fun refreshMainPage() { + // the url/title on the main page is only refreshed if + // we're navigating to the main env page from another page. + // If TBX is already on the main page the title is not refreshed + // hence we force a navigation from a blank page. ui.showUiPage(CoderPage.emptyPage(this)) + + + // Toolbox uses an internal shared flow with a buffer of 4 items and a DROP_OLDEST strategy. + // Both showUiPage and showPluginEnvironmentsPage send events to this flow. + // If we emit two events back-to-back, the first one often gets dropped and only the second is shown. + // To reduce this risk, we add a small delay to let the UI coroutine process the first event. + // Simply yielding the coroutine isn't reliable, especially right after Toolbox starts via URI handling. + // Based on my testing, a 5–10 ms delay is enough to ensure the blank page is processed, + // while still short enough to be invisible to users. delay(10.milliseconds) envPageManager.showPluginEnvironmentsPage() } From db82812f8ad1ca3626f959c7c843df018fe9faec Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 12 Jun 2025 00:02:24 +0300 Subject: [PATCH 4/4] fix: provide latest resources for agent matching Before agent matching we check the state of a workspace and if it is stopped we automatically start it (if Disable autostart is not enabled). However the agent matching code still received an old copy of the stopped workspace. Stopped workspaces don't have resources. --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 1 - .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 3a72a84..0e01ee3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -109,7 +109,6 @@ class CoderRemoteProvider( return@launch } - // Reconfigure if environments changed. if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 223974e..90f3465 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -75,7 +75,12 @@ open class CoderProtocolHandler( 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 - agent = resolveAgent(params, workspace) ?: return + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + agent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return if (!ensureAgentIsReady(workspace, agent)) return } finally { unmarkAsBusy()