From e6d95773abb736ac94ceb935007f388d35552e43 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 4 Jun 2026 01:07:20 +0300 Subject: [PATCH 1/3] fix: decouple snackbar display from caller so busy state always resets logAndShowError/Warning/Info were suspend functions that called ui.showSnackbar directly on the caller's coroutine. Toolbox keeps that coroutine suspended for the entire lifetime of the snackbar and cancels it (rather than resuming) when the snackbar is dismissed. As a result, dismissing a snackbar cancelled the calling coroutine and skipped any code meant to run after the error was shown - most visibly leaving coderHeaderPage.isBusy stuck at true after a failed URI handling. Introduce a fire-and-forget CoderToolboxContext.showSnackbar that launches the snackbar on the plugin scope, swallowing the expected CancellationException on dismissal. The logAndShow* helpers become regular (non-suspend) functions delegating to it, so callers continue immediately and their cleanup always runs. Also harden the busy-state handling in CoderRemoteProvider.handleUri and deferredLinkHandler with try/finally so isBusy is reset on every path, and drop the fragile onConnect.andThen { isBusy = false } chaining. ErrorReporter now reuses context.showSnackbar instead of its own launch. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 23 ++++--- .../com/coder/toolbox/CoderToolboxContext.kt | 68 +++++++++++-------- .../com/coder/toolbox/views/ErrorReporter.kt | 12 +--- 3 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 972497e..069f236 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -360,15 +360,18 @@ class CoderRemoteProvider( context.logger.info("Handling $uri...") val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return - coderHeaderPage.isBusy.update { true } if (sameUrl(newUrl, client?.url)) { - if (context.settingsStore.requiresTokenAuth) { - newToken?.let { - refreshSession(newUrl, it) + coderHeaderPage.isBusy.update { true } + try { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) + } } + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + } finally { + coderHeaderPage.isBusy.update { false } } - linkHandler.handle(params, newUrl, this.client!!, this.cli!!) - coderHeaderPage.isBusy.update { false } } else { // Different URL - we need a new connection. Tear down any // in-flight wizard, install a fresh one on the router, and let @@ -378,10 +381,7 @@ class CoderRemoteProvider( context, settingsPage, visibilityState, url = newUrl, credentials = credentials, - onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) - .andThen { _, _ -> - coderHeaderPage.isBusy.update { false } - }, + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)), onTokenRefreshed = ::onTokenRefreshed, ) router.navigate(wizard) @@ -654,6 +654,7 @@ class CoderRemoteProvider( deploymentUrl: URL, ): SuspendBiConsumer = SuspendBiConsumer { client, cli -> context.cs.launch(CoroutineName("Deferred Link Handler")) { + coderHeaderPage.isBusy.update { true } try { linkHandler.handle(params, deploymentUrl, client, cli) } catch (ex: Exception) { @@ -661,6 +662,8 @@ class CoderRemoteProvider( "Error handling deferred link", ex.message ?: "" ) + } finally { + coderHeaderPage.isBusy.update { false } } } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 5308067..1b14622 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -13,7 +13,10 @@ 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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.net.URL import java.util.UUID @@ -49,44 +52,53 @@ data class CoderToolboxContext( ?: settingsStore.defaultURL.toURL() } - suspend fun logAndShowError(title: String, error: String) { + fun logAndShowError(title: String, error: String) { logger.error(error) - ui.showSnackbar( - UUID.randomUUID().toString(), - i18n.pnotr(title), - i18n.pnotr(error), - i18n.ptrl("OK") - ) + showSnackbar(title, error) } - suspend fun logAndShowError(title: String, error: String, exception: Exception) { + 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") - ) + showSnackbar(title, error) } - suspend fun logAndShowWarning(title: String, warning: String) { + fun logAndShowWarning(title: String, warning: String) { logger.warn(warning) - ui.showSnackbar( - UUID.randomUUID().toString(), - i18n.pnotr(title), - i18n.pnotr(warning), - i18n.ptrl("OK") - ) + showSnackbar(title, warning) } - suspend fun logAndShowInfo(title: String, info: String) { + fun logAndShowInfo(title: String, info: String) { logger.info(info) - ui.showSnackbar( - UUID.randomUUID().toString(), - i18n.pnotr(title), - i18n.pnotr(info), - i18n.ptrl("OK") - ) + showSnackbar(title, info) + } + + /** + * Displays a snackbar on a child of the plugin coroutine scope rather than on the + * caller's coroutine, without waiting for it. + * + * Toolbox keeps the [ToolboxUi.showSnackbar] coroutine suspended for the entire lifetime + * of the snackbar and cancels it (rather than resuming it) when the snackbar goes away. + * Calling it directly on the caller's coroutine would therefore either block the caller + * until the snackbar is gone or, on dismissal, abruptly cancel the caller (e.g. the URI + * handler) - skipping any code that runs after the error is shown, such as resetting the + * busy state. Launching it fire-and-forget on the plugin scope lets the caller continue + * immediately while the snackbar lives independently. + */ + fun showSnackbar(title: String, text: String) { + cs.launch(CoroutineName("snackbar")) { + try { + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(text), + i18n.ptrl("OK") + ) + } catch (_: CancellationException) { + // Expected when the snackbar is dismissed or the plugin scope shuts down. + } catch (ex: Exception) { + logger.error(ex, "Failed to display snackbar with title '$title'") + } + } } fun popupPluginMainPage() { diff --git a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt index e6d6ac5..1549bfd 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt @@ -4,8 +4,6 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.prettify import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import java.util.UUID sealed class ErrorReporter { @@ -47,15 +45,7 @@ private class ErrorReporterImpl( private fun showError(ex: Throwable) { val textError = ex.prettify() - - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while setting up Coder"), - context.i18n.pnotr(textError), - context.i18n.ptrl("Dismiss") - ) - } + context.showSnackbar("Error encountered while setting up Coder", textError) } override fun flush() { From ad1cb403f827ef601368b3ab696649d64eaecf4a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 4 Jun 2026 22:17:41 +0300 Subject: [PATCH 2/3] Remove unnecessary parent catch header reset --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 069f236..debc80c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -397,7 +397,6 @@ class CoderRemoteProvider( "Error encountered while handling Coder URI", textError ?: "" ) - coderHeaderPage.isBusy.update { false } } finally { firstRun = false } From 33c372d43f3a1813119f774daf39c84b7bb6376e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 4 Jun 2026 22:23:34 +0300 Subject: [PATCH 3/3] chore: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd84b7..5013b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Fixed + +- snackbar dismissal no longer cancels the calling coroutine, so error popups during URI handling don't leave the page + stuck in a busy state + ### Changed - skip the Coder TLS alternate hostname when fetching IDE metadata from JetBrains