Skip to content

Commit e6d9577

Browse files
committed
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.
1 parent 54c1d54 commit e6d9577

3 files changed

Lines changed: 54 additions & 49 deletions

File tree

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -360,15 +360,18 @@ class CoderRemoteProvider(
360360
context.logger.info("Handling $uri...")
361361
val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return
362362
val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return
363-
coderHeaderPage.isBusy.update { true }
364363
if (sameUrl(newUrl, client?.url)) {
365-
if (context.settingsStore.requiresTokenAuth) {
366-
newToken?.let {
367-
refreshSession(newUrl, it)
364+
coderHeaderPage.isBusy.update { true }
365+
try {
366+
if (context.settingsStore.requiresTokenAuth) {
367+
newToken?.let {
368+
refreshSession(newUrl, it)
369+
}
368370
}
371+
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
372+
} finally {
373+
coderHeaderPage.isBusy.update { false }
369374
}
370-
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
371-
coderHeaderPage.isBusy.update { false }
372375
} else {
373376
// Different URL - we need a new connection. Tear down any
374377
// in-flight wizard, install a fresh one on the router, and let
@@ -378,10 +381,7 @@ class CoderRemoteProvider(
378381
context, settingsPage, visibilityState,
379382
url = newUrl,
380383
credentials = credentials,
381-
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
382-
.andThen { _, _ ->
383-
coderHeaderPage.isBusy.update { false }
384-
},
384+
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)),
385385
onTokenRefreshed = ::onTokenRefreshed,
386386
)
387387
router.navigate(wizard)
@@ -654,13 +654,16 @@ class CoderRemoteProvider(
654654
deploymentUrl: URL,
655655
): SuspendBiConsumer<CoderRestClient, CoderCLIManager> = SuspendBiConsumer { client, cli ->
656656
context.cs.launch(CoroutineName("Deferred Link Handler")) {
657+
coderHeaderPage.isBusy.update { true }
657658
try {
658659
linkHandler.handle(params, deploymentUrl, client, cli)
659660
} catch (ex: Exception) {
660661
context.logAndShowError(
661662
"Error handling deferred link",
662663
ex.message ?: ""
663664
)
665+
} finally {
666+
coderHeaderPage.isBusy.update { false }
664667
}
665668
}
666669
}

src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
1313
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
1414
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
1515
import com.jetbrains.toolbox.api.ui.ToolboxUi
16+
import kotlinx.coroutines.CancellationException
17+
import kotlinx.coroutines.CoroutineName
1618
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.launch
1720
import java.net.URL
1821
import java.util.UUID
1922

@@ -49,44 +52,53 @@ data class CoderToolboxContext(
4952
?: settingsStore.defaultURL.toURL()
5053
}
5154

52-
suspend fun logAndShowError(title: String, error: String) {
55+
fun logAndShowError(title: String, error: String) {
5356
logger.error(error)
54-
ui.showSnackbar(
55-
UUID.randomUUID().toString(),
56-
i18n.pnotr(title),
57-
i18n.pnotr(error),
58-
i18n.ptrl("OK")
59-
)
57+
showSnackbar(title, error)
6058
}
6159

62-
suspend fun logAndShowError(title: String, error: String, exception: Exception) {
60+
fun logAndShowError(title: String, error: String, exception: Exception) {
6361
logger.error(exception, error)
64-
ui.showSnackbar(
65-
UUID.randomUUID().toString(),
66-
i18n.pnotr(title),
67-
i18n.pnotr(error),
68-
i18n.ptrl("OK")
69-
)
62+
showSnackbar(title, error)
7063
}
7164

72-
suspend fun logAndShowWarning(title: String, warning: String) {
65+
fun logAndShowWarning(title: String, warning: String) {
7366
logger.warn(warning)
74-
ui.showSnackbar(
75-
UUID.randomUUID().toString(),
76-
i18n.pnotr(title),
77-
i18n.pnotr(warning),
78-
i18n.ptrl("OK")
79-
)
67+
showSnackbar(title, warning)
8068
}
8169

82-
suspend fun logAndShowInfo(title: String, info: String) {
70+
fun logAndShowInfo(title: String, info: String) {
8371
logger.info(info)
84-
ui.showSnackbar(
85-
UUID.randomUUID().toString(),
86-
i18n.pnotr(title),
87-
i18n.pnotr(info),
88-
i18n.ptrl("OK")
89-
)
72+
showSnackbar(title, info)
73+
}
74+
75+
/**
76+
* Displays a snackbar on a child of the plugin coroutine scope rather than on the
77+
* caller's coroutine, without waiting for it.
78+
*
79+
* Toolbox keeps the [ToolboxUi.showSnackbar] coroutine suspended for the entire lifetime
80+
* of the snackbar and cancels it (rather than resuming it) when the snackbar goes away.
81+
* Calling it directly on the caller's coroutine would therefore either block the caller
82+
* until the snackbar is gone or, on dismissal, abruptly cancel the caller (e.g. the URI
83+
* handler) - skipping any code that runs after the error is shown, such as resetting the
84+
* busy state. Launching it fire-and-forget on the plugin scope lets the caller continue
85+
* immediately while the snackbar lives independently.
86+
*/
87+
fun showSnackbar(title: String, text: String) {
88+
cs.launch(CoroutineName("snackbar")) {
89+
try {
90+
ui.showSnackbar(
91+
UUID.randomUUID().toString(),
92+
i18n.pnotr(title),
93+
i18n.pnotr(text),
94+
i18n.ptrl("OK")
95+
)
96+
} catch (_: CancellationException) {
97+
// Expected when the snackbar is dismissed or the plugin scope shuts down.
98+
} catch (ex: Exception) {
99+
logger.error(ex, "Failed to display snackbar with title '$title'")
100+
}
101+
}
90102
}
91103

92104
fun popupPluginMainPage() {

src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.util.prettify
55
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
66
import kotlinx.coroutines.flow.StateFlow
7-
import kotlinx.coroutines.launch
8-
import java.util.UUID
97

108
sealed class ErrorReporter {
119

@@ -47,15 +45,7 @@ private class ErrorReporterImpl(
4745

4846
private fun showError(ex: Throwable) {
4947
val textError = ex.prettify()
50-
51-
context.cs.launch {
52-
context.ui.showSnackbar(
53-
UUID.randomUUID().toString(),
54-
context.i18n.ptrl("Error encountered while setting up Coder"),
55-
context.i18n.pnotr(textError),
56-
context.i18n.ptrl("Dismiss")
57-
)
58-
}
48+
context.showSnackbar("Error encountered while setting up Coder", textError)
5949
}
6050

6151
override fun flush() {

0 commit comments

Comments
 (0)