Skip to content

Commit efe0b1f

Browse files
committed
Fix inconsistent UI state and concurrent setup flow in Toolbox wizard
Toolbox currently relies on getOverrideUiPage to decide whether to render a custom page or fall back to environments, but this logic is stateless and leads to inconsistent behavior when the window is closed and reopened. If the user exits Toolbox mid-setup (e.g., during CLI download in ConnectStep) and reopens it, a new wizard instance is created even though the previous setup process is still running in the background. This results in multiple overlapping setup flows, race conditions, and UI inconsistencies such as: - Returning to the initial URL step while setup is still in progress - Conflicting login attempts when restarting the wizard - Background processes (CLI download, HTTP setup) continuing without visible UI - Security dialogs triggered due to shared/overwritten resources - Undefined behavior when switching deployments mid-setup The root cause is lack of persistent, centralized state management for the setup flow. Current reliance on static state (e.g., CoderSetupWizardState) and recomputation via getOverrideUiPage does not properly track in-progress operations or restore the correct UI state. - resolves https://linear.app/codercom/issue/DEVEX-223/
1 parent 8d2a2aa commit efe0b1f

8 files changed

Lines changed: 172 additions & 156 deletions

File tree

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

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import com.coder.toolbox.oauth.OAuth2Client
77
import com.coder.toolbox.plugin.PluginManager
88
import com.coder.toolbox.sdk.CoderRestClient
99
import com.coder.toolbox.views.state.CoderOAuthSessionContext
10-
import com.coder.toolbox.views.state.CoderSetupWizardContext
11-
import com.coder.toolbox.views.state.CoderSetupWizardState
10+
import com.coder.toolbox.views.state.WizardModel
1211
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1312
import com.jetbrains.toolbox.api.ui.components.LabelField
1413
import com.jetbrains.toolbox.api.ui.components.RowGroup
@@ -30,6 +29,7 @@ private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button"
3029
*/
3130
class ConnectStep(
3231
private val context: CoderToolboxContext,
32+
private val model: WizardModel,
3333
private val shouldAutoLogin: StateFlow<Boolean>,
3434
private val jumpToMainPageOnError: Boolean,
3535
visibilityState: StateFlow<ProviderVisibilityState>,
@@ -54,7 +54,7 @@ class ConnectStep(
5454
context.i18n.pnotr("")
5555
}
5656

57-
if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.isNotReadyForAuth()) {
57+
if (context.settingsStore.requiresTokenAuth && model.isNotReadyForAuth()) {
5858
errorField.textState.update {
5959
context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!")
6060
}
@@ -67,21 +67,21 @@ class ConnectStep(
6767
return
6868
}
6969

70-
statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderSetupWizardContext.url?.host ?: "unknown host"}...") }
70+
statusField.textState.update { context.i18n.pnotr("Connecting to ${model.url?.host ?: "unknown host"}...") }
7171
connect()
7272
}
7373

7474
/**
7575
* Try connecting to Coder with the provided URL and token.
7676
*/
7777
private fun connect() {
78-
val url = CoderSetupWizardContext.url
78+
val url = model.url
7979
if (url == null) {
8080
errorField.textState.update { context.i18n.ptrl("URL is required") }
8181
return
8282
}
8383

84-
if (context.settingsStore.requiresTokenAuth && !CoderSetupWizardContext.hasToken() && !CoderSetupWizardContext.hasOAuthSession()) {
84+
if (context.settingsStore.requiresTokenAuth && !model.hasToken() && !model.hasOAuthSession()) {
8585
errorField.textState.update { context.i18n.ptrl("Token is required") }
8686
return
8787
}
@@ -96,12 +96,12 @@ class ConnectStep(
9696
val connectionLogic: suspend CoroutineScope.() -> Unit = {
9797
try {
9898
var oauthSession: CoderOAuthSessionContext? = null
99-
if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable && CoderSetupWizardContext.hasOAuthSession()) {
99+
if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable && model.hasOAuthSession()) {
100100
refreshOAuthToken()
101-
oauthSession = CoderSetupWizardContext.oauthSession!!.copy()
101+
oauthSession = model.oauthSession!!.copy()
102102
}
103103

104-
val apiToken = if (context.settingsStore.requiresTokenAuth) CoderSetupWizardContext.token else null
104+
val apiToken = if (context.settingsStore.requiresTokenAuth) model.token else null
105105

106106
context.logger.info("Setting up the HTTP client...")
107107
val client = CoderRestClient(
@@ -142,8 +142,8 @@ class ConnectStep(
142142
oauthSession?.let { session ->
143143
onTokenRefreshed?.invoke(client.url, session)
144144
}
145-
CoderSetupWizardContext.reset()
146-
CoderSetupWizardState.goToDone()
145+
model.clearFormData()
146+
model.goToDone()
147147
context.envPageManager.showPluginEnvironmentsPage()
148148
} catch (ex: CancellationException) {
149149
if (ex.message != USER_HIT_THE_BACK_BUTTON) {
@@ -162,13 +162,13 @@ class ConnectStep(
162162
}
163163

164164
private suspend fun refreshOAuthToken() {
165-
val session = CoderSetupWizardContext.oauthSession ?: return
165+
val session = model.oauthSession ?: return
166166
if (!session.tokenResponse?.accessToken.isNullOrBlank()) return
167167

168168
logAndReportProgress("Refreshing OAuth token...")
169169
val tokenResponse = OAuth2Client(context).refreshToken(session)
170170
context.logger.info("Successfully refreshed access token")
171-
CoderSetupWizardContext.oauthSession = session.copy(tokenResponse = tokenResponse)
171+
model.oauthSession = session.copy(tokenResponse = tokenResponse)
172172
}
173173

174174
private fun logAndReportProgress(msg: String) {
@@ -181,17 +181,17 @@ class ConnectStep(
181181
*/
182182
private fun handleNavigation() {
183183
if (shouldAutoLogin.value) {
184-
CoderSetupWizardContext.reset()
184+
model.clearFormData()
185185
if (jumpToMainPageOnError) {
186186
context.popupPluginMainPage()
187187
} else {
188-
CoderSetupWizardState.goToFirstStep()
188+
model.goToFirst()
189189
}
190190
} else {
191191
if (context.settingsStore.requiresTokenAuth) {
192-
CoderSetupWizardState.goToPreviousStep()
192+
model.goToPrevious()
193193
} else {
194-
CoderSetupWizardState.goToFirstStep()
194+
model.goToFirst()
195195
}
196196
}
197197
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import com.coder.toolbox.util.WebUrlValidationResult.Invalid
1111
import com.coder.toolbox.util.toURL
1212
import com.coder.toolbox.util.validateStrictWebUrl
1313
import com.coder.toolbox.views.state.CoderOAuthSessionContext
14-
import com.coder.toolbox.views.state.CoderSetupWizardContext
15-
import com.coder.toolbox.views.state.CoderSetupWizardState
14+
import com.coder.toolbox.views.state.WizardModel
1615
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1716
import com.jetbrains.toolbox.api.ui.components.CheckboxField
1817
import com.jetbrains.toolbox.api.ui.components.LabelField
@@ -40,6 +39,7 @@ private const val OAUTH2_SCOPE: String =
4039
*/
4140
class DeploymentUrlStep(
4241
private val context: CoderToolboxContext,
42+
private val model: WizardModel,
4343
visibilityState: StateFlow<ProviderVisibilityState>,
4444
) :
4545
WizardStep {
@@ -94,28 +94,28 @@ class DeploymentUrlStep(
9494
}
9595

9696
try {
97-
CoderSetupWizardContext.url = validateRawUrl(rawUrl)
97+
model.url = validateRawUrl(rawUrl)
9898
} catch (e: MalformedURLException) {
9999
errorReporter.report("URL is invalid", e)
100100
return false
101101
}
102102

103103
if (context.settingsStore.requiresMTlsAuth) {
104-
CoderSetupWizardState.goToLastStep()
104+
model.goToLast()
105105
return true
106106
}
107107
if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable) {
108108
try {
109109
context.logger.info("Prefers OAuth2 authentication")
110-
CoderSetupWizardContext.oauthSession = handleOAuth2(rawUrl)
110+
model.oauthSession = handleOAuth2(rawUrl)
111111
return false
112112
} catch (e: Exception) {
113113
errorReporter.report("Failed to authenticate with OAuth2: ${e.message}", e)
114114
return false
115115
}
116116
}
117117
// if all else fails try the good old API token auth
118-
CoderSetupWizardState.goToNextStep()
118+
model.goToNext()
119119
return true
120120
}
121121

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ package com.coder.toolbox.views
22

33
import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.util.withPath
5-
import com.coder.toolbox.views.state.CoderSetupWizardContext
6-
import com.coder.toolbox.views.state.CoderSetupWizardState
5+
import com.coder.toolbox.views.state.WizardModel
76
import com.jetbrains.toolbox.api.ui.components.LinkField
87
import com.jetbrains.toolbox.api.ui.components.RowGroup
98
import com.jetbrains.toolbox.api.ui.components.TextField
@@ -20,6 +19,7 @@ import kotlinx.coroutines.flow.update
2019
*/
2120
class TokenStep(
2221
private val context: CoderToolboxContext,
22+
private val model: WizardModel,
2323
) : WizardStep {
2424
private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password)
2525
private val linkField = LinkField(context.i18n.ptrl("Get a token"), "")
@@ -35,9 +35,9 @@ class TokenStep(
3535
errorField.textState.update {
3636
context.i18n.pnotr("")
3737
}
38-
if (CoderSetupWizardContext.hasUrl()) {
38+
if (model.hasUrl()) {
3939
tokenField.textState.update {
40-
context.secrets.apiTokenFor(CoderSetupWizardContext.url!!) ?: ""
40+
context.secrets.apiTokenFor(model.url!!) ?: ""
4141
}
4242
} else {
4343
errorField.textState.update {
@@ -46,7 +46,7 @@ class TokenStep(
4646
}
4747
}
4848
(linkField.urlState as MutableStateFlow).update {
49-
CoderSetupWizardContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: ""
49+
model.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: ""
5050
}
5151
}
5252

@@ -57,12 +57,12 @@ class TokenStep(
5757
return false
5858
}
5959

60-
CoderSetupWizardContext.token = token
61-
CoderSetupWizardState.goToNextStep()
60+
model.token = token
61+
model.goToNext()
6262
return true
6363
}
6464

6565
override fun onBack() {
66-
CoderSetupWizardState.goToPreviousStep()
66+
model.goToPrevious()
6767
}
6868
}

src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt

Lines changed: 0 additions & 78 deletions
This file was deleted.

src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt

Lines changed: 0 additions & 47 deletions
This file was deleted.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.coder.toolbox.views.state
2+
3+
import com.coder.toolbox.oauth.OAuthTokenResponse
4+
import com.coder.toolbox.oauth.TokenEndpointAuthMethod
5+
6+
data class CoderOAuthSessionContext(
7+
val clientId: String,
8+
val clientSecret: String,
9+
val tokenCodeVerifier: String,
10+
val state: String,
11+
val tokenEndpoint: String,
12+
val tokenResponse: OAuthTokenResponse? = null,
13+
val tokenAuthMethod: TokenEndpointAuthMethod
14+
)
15+
16+
data class StoredOAuthSession(
17+
val clientId: String,
18+
val clientSecret: String,
19+
val refreshToken: String,
20+
val tokenAuthMethod: TokenEndpointAuthMethod,
21+
val tokenEndpoint: String
22+
)
23+
24+
fun CoderOAuthSessionContext?.hasRefreshToken(): Boolean = this?.tokenResponse?.refreshToken != null

0 commit comments

Comments
 (0)