Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Changed

- skip the Coder TLS alternate hostname when fetching IDE metadata from JetBrains
- notifications are now persistent popups instead of snackbars, so they survive a hidden window and no longer get dropped

## 0.9.0 - 2026-05-14

Expand Down
39 changes: 13 additions & 26 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,6 @@ class CoderRemoteProvider(
}
}
}
private val visibilityState = MutableStateFlow(
ProviderVisibilityState(
applicationVisible = false,
providerVisible = false
)
)
private val linkHandler = CoderProtocolHandler(context, IdeFeedManager(context))

override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...")
Expand All @@ -109,8 +103,6 @@ class CoderRemoteProvider(
context.envPageManager.showPluginEnvironmentsPage()
}

private val errorBuffer = mutableListOf<Throwable>()

private val router = PageRouter()

/**
Expand Down Expand Up @@ -158,13 +150,17 @@ class CoderRemoteProvider(
if (elapsed > POLL_INTERVAL * 2) {
context.logger.info("wake-up from an OS sleep was detected")
} else {
context.logger.error(ex, "workspace polling error encountered")
if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) {
close()
context.envPageManager.showPluginEnvironmentsPage()
errorBuffer.add(ex)
context.logAndShowError(
"Error encountered while setting up Coder",
"Your Coder session has expired. Please re-authenticate and try again.",
ex
)
break
}
context.logger.error(ex, "workspace polling error encountered")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it may be worth distinguishing this error log from the one above it, unless they suggest the same issue and remedy

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Thx.

}
}

Expand Down Expand Up @@ -331,9 +327,6 @@ class CoderRemoteProvider(
* and a manual refresh button.
*/
override fun setVisible(visibility: ProviderVisibilityState) {
visibilityState.update {
visibility
}
if (visibility.providerVisible) {
context.cs.launch(CoroutineName("Notify Plugin Visibility")) {
triggerProviderVisible.send(true)
Expand Down Expand Up @@ -378,7 +371,7 @@ class CoderRemoteProvider(
// showPluginEnvironmentsPage() pull it through getOverrideUiPage.
val credentials = newToken?.let { Credentials.Token(it) } ?: Credentials.MTls
val wizard = CoderSetupWizardPage.connectStep(
context, settingsPage, visibilityState,
context, settingsPage,
url = newUrl,
credentials = credentials,
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)),
Expand Down Expand Up @@ -458,7 +451,7 @@ class CoderRemoteProvider(
val oauthSessionContext = pendingOAuthConnection.session
val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code)
val wizard = CoderSetupWizardPage.connectStep(
context, settingsPage, visibilityState,
context, settingsPage,
url = pendingOAuthConnection.url,
credentials = Credentials.OAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)),
onConnect = onConnect,
Expand Down Expand Up @@ -555,36 +548,30 @@ class CoderRemoteProvider(
try {
val url = context.deploymentUrl
val credentials = autoSetupCredentials(url) ?: return CoderSetupWizardPage.deploymentUrlStep(
context, settingsPage, visibilityState,
context, settingsPage,
onConnect = onConnect,
onTokenRefreshed = ::onTokenRefreshed,
)
return CoderSetupWizardPage.connectStep(
context, settingsPage, visibilityState,
context, settingsPage,
url = url,
credentials = credentials,
onConnect = onConnect,
onTokenRefreshed = ::onTokenRefreshed,
)
} catch (ex: Exception) {
errorBuffer.add(ex)
context.logAndShowError("Error encountered while setting up Coder", "Failed to set up Coder", ex)
} finally {
firstRun = false
}
}

// Login flow.
val setupWizardPage = CoderSetupWizardPage.deploymentUrlStep(
context, settingsPage, visibilityState,
return CoderSetupWizardPage.deploymentUrlStep(
context, settingsPage,
onConnect = onConnect,
onTokenRefreshed = ::onTokenRefreshed,
)
// We might have navigated here due to a polling error.
errorBuffer.forEach {
setupWizardPage.notify("Error encountered", it)
}
errorBuffer.clear()
return setupWizardPage
}

/**
Expand Down
43 changes: 22 additions & 21 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL
import java.util.UUID

@Suppress("UnstableApiUsage")
data class CoderToolboxContext(
Expand All @@ -34,8 +33,9 @@ data class CoderToolboxContext(
val settingsStore: CoderSettingsStore,
val secrets: CoderSecretsStore,
val proxySettings: ToolboxProxySettings,
val connectionMonitoringService: ConnectionMonitoringService,
) {
val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this)

/**
* Try to find a URL.
*
Expand All @@ -54,49 +54,50 @@ data class CoderToolboxContext(

fun logAndShowError(title: String, error: String) {
logger.error(error)
showSnackbar(title, error)
showInfoPopup(title, error)
}

fun logAndShowError(title: String, error: String, exception: Exception) {
logger.error(exception, error)
showSnackbar(title, error)
showInfoPopup(title, error)
}

fun logAndShowWarning(title: String, warning: String) {
logger.warn(warning)
showSnackbar(title, warning)
showInfoPopup(title, warning)
}

fun logAndShowInfo(title: String, info: String) {
logger.info(info)
showSnackbar(title, info)
showInfoPopup(title, info)
}

/**
* Displays a snackbar on a child of the plugin coroutine scope rather than on the
* caller's coroutine, without waiting for it.
* Displays an informational popup on a child of the plugin coroutine scope rather than on
* the caller's coroutine, without waiting for it.
*
* Unlike [ToolboxUi.showSnackbar], a popup is backed by a persistent dialog state: it is
* still rendered once the window becomes visible even if it was requested while the window
* was hidden, it is not silently dropped when several are requested, and dismissing it
* resumes the [ToolboxUi.showInfoPopup] coroutine normally instead of cancelling 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.
* It is launched fire-and-forget so the caller is not suspended until the user closes the
* popup - the caller (e.g. the URI handler) can run any follow-up code, such as resetting
* the busy state, immediately. The popups are serialized via [popupMutex] so they are
* shown one after another rather than overwriting each other.
*/
fun showSnackbar(title: String, text: String) {
cs.launch(CoroutineName("snackbar")) {
fun showInfoPopup(title: String, text: String) {
cs.launch(CoroutineName("popup")) {
try {
ui.showSnackbar(
UUID.randomUUID().toString(),
ui.showInfoPopup(
i18n.pnotr(title),
i18n.pnotr(text),
i18n.ptrl("OK")
)
} catch (_: CancellationException) {
// Expected when the snackbar is dismissed or the plugin scope shuts down.
// Expected when the plugin scope shuts down while the popup is open.
} catch (ex: Exception) {
logger.error(ex, "Failed to display snackbar with title '$title'")
logger.error(ex, "Failed to display popup with title '$title'")
}
}
}
Expand Down
7 changes: 0 additions & 7 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.coder.toolbox
import com.coder.toolbox.settings.Environment
import com.coder.toolbox.store.CoderSecretsStore
import com.coder.toolbox.store.CoderSettingsStore
import com.coder.toolbox.util.ConnectionMonitoringService
import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.jetbrains.toolbox.api.core.ServiceLocator
Expand Down Expand Up @@ -45,12 +44,6 @@ class CoderToolboxExtension : RemoteDevExtension {
CoderSettingsStore(serviceLocator.getService<PluginSettingsStore>(), Environment(), logger),
CoderSecretsStore(serviceLocator.getService<PluginSecretStore>()),
serviceLocator.getService<ToolboxProxySettings>(),
ConnectionMonitoringService(
cs,
ui,
logger,
i18n
)
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout
import java.net.URL
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -273,8 +272,7 @@ open class CoderProtocolHandler(
context.logger.info("Successfully installed $selectedIde on $environmentId.")
return selectedIde
} else {
context.ui.showSnackbar(
UUID.randomUUID().toString(),
context.ui.showInfoPopup(
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
package com.coder.toolbox.util

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
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.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.ui.ToolboxUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.UUID

class ConnectionMonitoringService(
private val cs: CoroutineScope,
private val ui: ToolboxUi,
private val logger: Logger,
private val i18n: LocalizableStringFactory
private val context: CoderToolboxContext
) {
private var alreadyNotified = false

Expand All @@ -34,25 +26,12 @@ class ConnectionMonitoringService(

when {
isWorkspaceRunning && isAgentReady && hasConnectionIssue -> {
cs.launch {
logAndShowWarning(
title = "Unstable connection detected",
warning = "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
)
}
context.logAndShowWarning(
"Unstable connection detected",
"Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
)
alreadyNotified = true
}
}
}


private suspend fun logAndShowWarning(title: String, warning: String) {
logger.warn(warning)
ui.showSnackbar(
UUID.randomUUID().toString(),
i18n.ptrl(title),
i18n.ptrl(warning),
i18n.ptrl("OK")
)
}
}
}
20 changes: 3 additions & 17 deletions src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,17 @@ import com.coder.toolbox.views.state.Credentials
import com.coder.toolbox.views.state.PendingOAuthConnection
import com.coder.toolbox.views.state.WizardModel
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.UiField
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.net.URL

class CoderSetupWizardPage private constructor(
private val context: CoderToolboxContext,
private val settingsPage: CoderSettingsPage,
visibilityState: StateFlow<ProviderVisibilityState>,
private var autoLogin: Boolean = false,
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null
Expand All @@ -31,17 +28,15 @@ class CoderSetupWizardPage private constructor(
context.ui.showUiPage(settingsPage)
}

private val deploymentUrlStep = DeploymentUrlStep(context, model, visibilityState)
private val deploymentUrlStep = DeploymentUrlStep(context, model)
private val tokenStep = TokenStep(context, model)
private val connectStep = ConnectStep(
context,
model,
visibilityState,
navigateBack = this::navigateBackFromConnect,
onConnect = onConnect,
onTokenRefreshed = onTokenRefreshed
)
private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass)
private var stateCollectJob: Job? = null

/**
Expand All @@ -58,7 +53,6 @@ class CoderSetupWizardPage private constructor(
displaySteps()
}
}
errorReporter.flush()
}

private fun displaySteps() {
Expand Down Expand Up @@ -153,35 +147,27 @@ class CoderSetupWizardPage private constructor(
stateCollectJob?.cancel()
}

/**
* Show an error as a popup on this page.
*/
fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex)


companion object {
fun deploymentUrlStep(
context: CoderToolboxContext,
settingsPage: CoderSettingsPage,
visibilityState: StateFlow<ProviderVisibilityState>,
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null,
): CoderSetupWizardPage = CoderSetupWizardPage(
context, settingsPage, visibilityState,
context, settingsPage,
onConnect = onConnect,
onTokenRefreshed = onTokenRefreshed,
).apply { model.goToFirst() }

fun connectStep(
context: CoderToolboxContext,
settingsPage: CoderSettingsPage,
visibilityState: StateFlow<ProviderVisibilityState>,
url: URL,
credentials: Credentials,
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null,
): CoderSetupWizardPage = CoderSetupWizardPage(
context, settingsPage, visibilityState,
context, settingsPage,
autoLogin = true,
onConnect = onConnect,
onTokenRefreshed = onTokenRefreshed,
Expand Down
Loading
Loading