diff --git a/CHANGELOG.md b/CHANGELOG.md index fa101fd..a61780a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Changed + +- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically + establish it after an expired token was refreshed. + +### Fixed + +- `Stop` action is now available for running workspaces that have an out of date template. +- outdated and stopped workspaces are now updated and started when handling URI +- show errors when the Toolbox is visible again after being minimized. + +## 0.3.0 - 2025-06-10 + ### Added - support for Toolbox 2.6.3 with improved URI handling diff --git a/README.md b/README.md index 2034504..56636f9 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,12 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable experience, it’s recommended to ensure the workspace is running prior to initiating the connection. -> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` tab. +> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` +> tab. > If the path refers to a project that doesn't exist, the remote IDE won’t start or load it. -> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a previously opened project or leave it empty. +> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a +> previously opened project or leave it empty. ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy @@ -144,11 +146,11 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: ## Debugging and Reporting issues -Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH -connections to the remote environment fail — it provides detailed output that includes SSH negotiation +Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH +connections to the remote environment fail — it provides detailed output that includes SSH negotiation and command execution, which is not visible at the default log level. -If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more +If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more information and help us diagnose and resolve it quickly. ### Enable Debug Logging @@ -164,7 +166,7 @@ Steps to enable debug logging: 3. In the screen that appears, select _DEBUG_ for the `Log level:` section. -4. Hit the back button at the top. +4. Hit the back button at the top. There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ level right away. @@ -172,10 +174,10 @@ There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ l #### Viewing the Logs -Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly +Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly via Toolbox App Menu > About > Show log files. -Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main +Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. ## Coder Settings @@ -183,19 +185,20 @@ Workspaces page in Coder or within the individual workspace view, under the opti The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data storage paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings. -### CLI related settings +### CLI related settings ```Binary source``` specifies the source URL or relative path from which the Coder CLI should be downloaded. If a relative path is provided, it is resolved against the deployment domain. ```Enable downloads``` allows automatic downloading of the CLI if the current version is missing or outdated. -```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data directory. +```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data +directory. ```Enable binary directory fallback``` if enabled, falls back to the data directory when the specified binary directory is not writable. -```Data directory``` directory where plugin-specific data such as session tokens and binaries are stored if not +```Data directory``` directory where plugin-specific data such as session tokens and binaries are stored if not overridden by the binary directory setting. ```Header command``` command that outputs additional HTTP headers. Each line of output must be in the format key=value. @@ -203,7 +206,8 @@ The environment variable CODER_URL will be available to the command process. ### TLS settings -The following options control the secure communication behavior of the plugin with Coder deployment and its available API. +The following options control the secure communication behavior of the plugin with Coder deployment and its available +API. ```TLS cert path``` path to a client certificate file for TLS authentication with Coder deployment. The certificate should be in X.509 PEM format. @@ -215,7 +219,7 @@ The certificate should be in X.509 PEM format. certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify proxy certificates. -```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname +```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname used to connect to the Coder deployment does not match the hostname in the TLS certificate. ### SSH settings @@ -232,11 +236,13 @@ rules for matching multiple workspaces. ```SSH network metrics directory``` directory where network information used by the SSH proxy is stored. -```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of SSH connections. +```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of +SSH connections. ### Saving Changes -Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard support, +Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard +support, may trigger regeneration of SSH configurations. ### Security considerations diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 0608817..3c4de20 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -126,16 +126,15 @@ class CoderRemoteEnvironment( update(workspace.copy(latestBuild = build), agent) } }) - } else { - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { - tryStopSshConnection() - - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) } + actions.add(Action(context.i18n.ptrl("Stop")) { + context.cs.launch { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + }) } return actions } @@ -235,8 +234,7 @@ class CoderRemoteEnvironment( * The contents are provided by the SSH view provided by Toolbox, all we * have to do is provide it a host name. */ - override suspend - fun getContentsView(): EnvironmentContentsView = EnvironmentView( + override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView( client.url, cli, workspace, @@ -244,19 +242,30 @@ class CoderRemoteEnvironment( ) /** - * Does nothing. In theory, we could do something like start the workspace - * when you click into the workspace, but you would still need to press - * "connect" anyway before the content is populated so there does not seem - * to be much value. + * Automatically launches the SSH connection if the workspace is visible, is ready and there is no + * connection already established. */ override fun setVisible(visibilityState: EnvironmentVisibilityState) { - if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) { + if (visibilityState.contentsVisible) { + startSshConnection() + } + } + + /** + * Launches the SSH connection if the workspace is ready and there is no connection already established. + * + * Returns true if the SSH connection was scheduled to start, false otherwise. + */ + fun startSshConnection(): Boolean { + if (wsRawStatus.ready() && !isConnected.value) { context.cs.launch { connectionRequest.update { true } } + return true } + return false } override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { @@ -299,6 +308,8 @@ class CoderRemoteEnvironment( } } + fun isConnected(): Boolean = isConnected.value + /** * An environment is equal if it has the same ID. */ diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 7aabdce..d72b130 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -3,13 +3,13 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager 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.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 @@ -20,7 +20,6 @@ import com.jetbrains.toolbox.api.core.util.LoadableState import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage @@ -66,10 +65,18 @@ class CoderRemoteProvider( private val isInitialized: MutableStateFlow = MutableStateFlow(false) private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - override val environments: MutableStateFlow>> = MutableStateFlow( + + override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Loading ) + private val visibilityState = MutableStateFlow( + ProviderVisibilityState( + applicationVisible = false, + providerVisible = false + ) + ) + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the @@ -110,7 +117,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") @@ -120,7 +126,7 @@ class CoderRemoteProvider( environments.update { LoadableState.Value(resolvedEnvironments.toList()) } - if (isInitialized.value == false) { + if (!isInitialized.value) { context.logger.info("Environments for ${client.url} are now initialized") isInitialized.update { true @@ -130,6 +136,21 @@ class CoderRemoteProvider( clear() addAll(resolvedEnvironments.sortedBy { it.id }) } + + if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) { + WorkspaceConnectionManager.allConnected().forEach { wsId -> + val env = lastEnvironments.firstOrNull() { it.id == wsId } + if (env != null && !env.isConnected()) { + context.logger.info("Establishing lost SSH connection for workspace with id $wsId") + if (!env.startSshConnection()) { + context.logger.info("Can't establish lost SSH connection for workspace with id $wsId") + } + } + } + WorkspaceConnectionManager.reset() + } + + WorkspaceConnectionManager.collectStatuses(lastEnvironments) } catch (_: CancellationException) { context.logger.debug("${client.url} polling loop canceled") break @@ -140,7 +161,12 @@ class CoderRemoteProvider( client.setupSession() } else { context.logger.error(ex, "workspace polling error encountered, trying to auto-login") + if (ex is APIResponseException && ex.isTokenExpired) { + WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + } close() + // force auto-login + firstRun = true goToEnvironmentsPage() break } @@ -170,6 +196,7 @@ class CoderRemoteProvider( // Keep the URL and token to make it easy to log back in, but set // rememberMe to false so we do not try to automatically log in. context.secrets.rememberMe = false + WorkspaceConnectionManager.reset() close() } @@ -263,18 +290,35 @@ class CoderRemoteProvider( * a place to put a timer ("last updated 10 seconds ago" for example) * and a manual refresh button. */ - override fun setVisible(visibilityState: ProviderVisibilityState) {} + override fun setVisible(visibility: ProviderVisibilityState) { + visibilityState.update { + visibility + } + } /** * 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) } @@ -309,7 +353,7 @@ class CoderRemoteProvider( if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { try { AuthWizardState.goToStep(WizardStep.LOGIN) - return AuthWizardPage(context, settingsPage, true, ::onConnect) + return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect) } catch (ex: Exception) { errorBuffer.add(ex) } @@ -319,7 +363,7 @@ class CoderRemoteProvider( firstRun = false // Login flow. - val authWizard = AuthWizardPage(context, settingsPage, false, ::onConnect) + val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) // We might have navigated here due to a polling error. errorBuffer.forEach { authWizard.notify("Error encountered", it) @@ -332,7 +376,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,11 +388,10 @@ 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() { + private fun MutableStateFlow>>.showLoadingMessage() { this.update { LoadableState.Loading } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 4291321..0bb4135 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,26 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + /** + * 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() + } } diff --git a/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt new file mode 100644 index 0000000..9196729 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox + +object WorkspaceConnectionManager { + private val workspaceConnectionState = mutableMapOf() + + var shouldEstablishWorkspaceConnections = false + + fun allConnected(): Set = workspaceConnectionState.filter { it.value }.map { it.key }.toSet() + + fun collectStatuses(workspaces: Set) { + workspaces.forEach { register(it.id, it.isConnected()) } + } + + private fun register(wsId: String, isConnected: Boolean) { + workspaceConnectionState[wsId] = isConnected + } + + fun reset() { + workspaceConnectionState.clear() + shouldEstablishWorkspaceConnections = false + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt index 9f78198..d109c75 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -8,8 +8,9 @@ import java.net.URL class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) : IOException(formatToPretty(action, url, code, errorResponse)) { - + val reason = errorResponse?.detail val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code + val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true companion object { private fun formatToPretty( diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index af07548..90f3465 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,27 @@ 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 + // 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() + } val environmentId = "${workspace.name}.${agent.name}" context.showEnvironmentPage(environmentId) @@ -173,7 +186,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, @@ -428,6 +445,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..affa96e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt @@ -3,24 +3,29 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.util.toURL import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState 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.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID class AuthWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, initialAutoLogin: Boolean = false, - onConnect: ( + onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder"), false) { +) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) { private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { context.ui.showUiPage(settingsPage) @@ -42,6 +47,8 @@ class AuthWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + private val errorBuffer = mutableListOf() + init { if (shouldAutoLogin.value) { AuthContext.url = context.secrets.lastDeploymentURL.toURL() @@ -51,6 +58,12 @@ class AuthWizardPage( override fun beforeShow() { displaySteps() + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } } private fun displaySteps() { @@ -113,4 +126,34 @@ class AuthWizardPage( } } } + + /** + * Show an error as a popup on this page. + */ + fun notify(logPrefix: String, ex: Throwable) { + context.logger.error(ex, logPrefix) + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered during authentication"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index ac94a36..9b83f45 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -6,8 +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.launch -import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow /** * Base page that handles the icon, displaying error notifications, and @@ -20,7 +19,6 @@ import java.util.UUID * to use the mouse. */ abstract class CoderPage( - private val context: CoderToolboxContext, title: LocalizableString, showIcon: Boolean = true, ) : UiPage(title) { @@ -39,20 +37,7 @@ abstract class CoderPage( SvgIcon(byteArrayOf(), type = IconType.Masked) } - /** - * Show an error as a popup on this page. - */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr(logPrefix), - context.i18n.pnotr(ex.message ?: ""), - context.i18n.ptrl("Dismiss") - ) - } - } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) companion object { fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index f888c3d..de2ce0b 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch * I have not been able to test this page. */ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : - CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { + CoderPage(context.i18n.ptrl("Coder Settings"), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test 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, diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 56b2910..83e07c7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -15,6 +15,6 @@ import kotlinx.coroutines.flow.StateFlow * support creating environments from the plugin. */ class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : - CoderPage(context, deploymentURL) { + CoderPage(deploymentURL) { override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 73da796..1b04695 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -140,4 +140,7 @@ msgid "Create workspace" msgstr "" msgid "Error encountered while handling Coder URI" +msgstr "" + +msgid "Error encountered during authentication" msgstr "" \ No newline at end of file