Skip to content

Commit aae32ea

Browse files
committed
impl: connect page router with URI handler
URI handler now schedules pages to be displayed via pager router and at the end trigger the window to show up which instead it will call getOverrideUiPage.
1 parent efe0b1f commit aae32ea

6 files changed

Lines changed: 131 additions & 110 deletions

File tree

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

Lines changed: 97 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ import com.coder.toolbox.views.CoderSettingsPage
2828
import com.coder.toolbox.views.NewEnvironmentPage
2929
import com.coder.toolbox.views.SuspendBiConsumer
3030
import com.coder.toolbox.views.state.CoderOAuthSessionContext
31-
import com.coder.toolbox.views.state.CoderSetupWizardContext
32-
import com.coder.toolbox.views.state.CoderSetupWizardState
31+
import com.coder.toolbox.views.state.PageRouter
3332
import com.coder.toolbox.views.state.WizardStep
3433
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
3534
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
@@ -113,6 +112,8 @@ class CoderRemoteProvider(
113112

114113
private val errorBuffer = mutableListOf<Throwable>()
115114

115+
private val router = PageRouter()
116+
116117
/**
117118
* With the provided client, start polling for workspaces. Every time a new
118119
* workspace is added, reconfigure SSH using the provided cli (including the
@@ -269,6 +270,7 @@ class CoderRemoteProvider(
269270
lastEnvironments.clear()
270271
environments.value = LoadableState.Value(emptyList())
271272
isInitialized.update { false }
273+
router.clear()
272274
context.logger.info("Coder plugin is now closed")
273275
}
274276

@@ -345,9 +347,6 @@ class CoderRemoteProvider(
345347
*/
346348
override suspend fun handleUri(uri: URI) {
347349
try {
348-
// Obtain focus. This switches to the main plugin screen, even
349-
// if last opened provider was not Coder
350-
context.envPageManager.showPluginEnvironmentsPage()
351350
if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) {
352351
handleOAuthUri(uri)
353352
return
@@ -372,25 +371,25 @@ class CoderRemoteProvider(
372371
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
373372
coderHeaderPage.isBusy.update { false }
374373
} else {
375-
// Different URL - we need a new connection.
376-
// Chain the link handling after onConnect so it runs once the connection is established.
377-
CoderSetupWizardContext.apply {
378-
url = newUrl
379-
token = newToken
380-
}
381-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
382-
context.ui.showUiPage(
383-
CoderCliSetupWizardPage(
384-
context, settingsPage, visibilityState,
385-
initialAutoSetup = true,
386-
jumpToMainPageOnError = true,
387-
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
388-
.andThen { _, _ ->
389-
coderHeaderPage.isBusy.update { false }
390-
},
391-
onTokenRefreshed = ::onTokenRefreshed
392-
)
374+
// Different URL - we need a new connection. Tear down any
375+
// in-flight wizard, install a fresh one on the router, and let
376+
// showPluginEnvironmentsPage() pull it through getOverrideUiPage.
377+
router.activeWizard?.dispose()
378+
val wizard = CoderCliSetupWizardPage(
379+
context, settingsPage, visibilityState,
380+
initialAutoSetup = true,
381+
jumpToMainPageOnError = true,
382+
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
383+
.andThen { _, _ ->
384+
coderHeaderPage.isBusy.update { false }
385+
},
386+
onTokenRefreshed = ::onTokenRefreshed
393387
)
388+
wizard.model.url = newUrl
389+
wizard.model.token = newToken
390+
wizard.model.goTo(WizardStep.CONNECT)
391+
router.replaceWith(wizard)
392+
context.envPageManager.showPluginEnvironmentsPage()
394393
}
395394
} catch (ex: Exception) {
396395
val textError = if (ex is APIResponseException) {
@@ -403,7 +402,6 @@ class CoderRemoteProvider(
403402
textError ?: ""
404403
)
405404
coderHeaderPage.isBusy.update { false }
406-
context.envPageManager.showPluginEnvironmentsPage()
407405
} finally {
408406
firstRun = false
409407
}
@@ -424,7 +422,15 @@ class CoderRemoteProvider(
424422
)
425423
}
426424

427-
params["state"]?.takeIf { it == CoderSetupWizardContext.oauthSession?.state }
425+
val activeWizard = router.activeWizard ?: return context.logAndShowError(
426+
FAILED_TO_HANDLE_OAUTH2_TITLE,
427+
"OAuth2 callback arrived but the setup wizard is no longer active"
428+
)
429+
val activeOAuthSession = activeWizard.model.oauthSession ?: return context.logAndShowError(
430+
FAILED_TO_HANDLE_OAUTH2_TITLE,
431+
"OAuth2 callback arrived but no OAuth session was started"
432+
)
433+
params["state"]?.takeIf { it == activeOAuthSession.state }
428434
?: return context.logAndShowError(
429435
FAILED_TO_HANDLE_OAUTH2_TITLE,
430436
"Server responded back with an invalid state that does not match the initial authorization state sent to the server"
@@ -442,18 +448,20 @@ class CoderRemoteProvider(
442448
)
443449
return
444450
}
445-
exchangeOAuthCodeForToken(code, CoderSetupWizardContext.oauthSession!!)
451+
exchangeOAuthCodeForToken(code, activeOAuthSession, activeWizard)
446452
}
447453

448-
private suspend fun exchangeOAuthCodeForToken(code: String, oauthSessionContext: CoderOAuthSessionContext) {
454+
private suspend fun exchangeOAuthCodeForToken(
455+
code: String,
456+
oauthSessionContext: CoderOAuthSessionContext,
457+
wizard: CoderCliSetupWizardPage,
458+
) {
449459
try {
450460
context.logger.info("Handling OAuth callback...")
451461

452462
val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code)
453-
CoderSetupWizardContext.oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse)
454-
455-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
456-
463+
wizard.model.oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse)
464+
wizard.model.goTo(WizardStep.CONNECT)
457465
} catch (e: Exception) {
458466
context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e)
459467
}
@@ -520,67 +528,70 @@ class CoderRemoteProvider(
520528
* list.
521529
*/
522530
override fun getOverrideUiPage(): UiPage? {
523-
// Show the setup page if we have not configured the client yet.
524-
if (client == null) {
525-
// When coming back to the application, initializeSession immediately.
526-
if (shouldDoAutoSetup()) {
527-
try {
528-
val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString())
529-
CoderSetupWizardContext.apply {
530-
url = context.deploymentUrl
531-
token = context.secrets.apiTokenFor(context.deploymentUrl)
532-
if (storedOAuthSession != null) {
533-
oauthSession = CoderOAuthSessionContext(
534-
clientId = storedOAuthSession.clientId,
535-
clientSecret = storedOAuthSession.clientSecret,
536-
tokenCodeVerifier = "",
537-
state = "",
538-
tokenEndpoint = storedOAuthSession.tokenEndpoint,
539-
tokenAuthMethod = storedOAuthSession.tokenAuthMethod,
540-
tokenResponse = OAuthTokenResponse(
541-
accessToken = "",
542-
tokenType = "",
543-
expiresIn = null,
544-
refreshToken = storedOAuthSession.refreshToken,
545-
scope = null
546-
)
547-
)
548-
}
549-
}
550-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
551-
return CoderCliSetupWizardPage(
552-
context, settingsPage, visibilityState,
553-
initialAutoSetup = true,
554-
jumpToMainPageOnError = false,
555-
onConnect = onConnect,
556-
onTokenRefreshed = ::onTokenRefreshed
557-
)
558-
} catch (ex: Exception) {
559-
errorBuffer.add(ex)
560-
} finally {
561-
firstRun = false
562-
}
563-
}
531+
if (client != null) return null
532+
return router.requireWizard { buildSetupWizard() }
533+
}
564534

565-
// Login flow.
566-
CoderSetupWizardState.goToFirstStep()
567-
val setupWizardPage =
568-
CoderCliSetupWizardPage(
569-
context,
570-
settingsPage,
571-
visibilityState,
535+
/**
536+
* Build the wizard for the current state. Called once per provider lifetime
537+
* (until [close] clears the router); subsequent visibility cycles reuse the
538+
* same instance, preserving any in-flight connect job.
539+
*/
540+
private fun buildSetupWizard(): CoderCliSetupWizardPage {
541+
// When coming back to the application, initializeSession immediately.
542+
if (shouldDoAutoSetup()) {
543+
try {
544+
val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString())
545+
val wizard = CoderCliSetupWizardPage(
546+
context, settingsPage, visibilityState,
547+
initialAutoSetup = true,
548+
jumpToMainPageOnError = false,
572549
onConnect = onConnect,
573550
onTokenRefreshed = ::onTokenRefreshed
574551
)
575-
// We might have navigated here due to a polling error.
576-
errorBuffer.forEach {
577-
setupWizardPage.notify("Error encountered", it)
552+
wizard.model.url = context.deploymentUrl
553+
wizard.model.token = context.secrets.apiTokenFor(context.deploymentUrl)
554+
if (storedOAuthSession != null) {
555+
wizard.model.oauthSession = CoderOAuthSessionContext(
556+
clientId = storedOAuthSession.clientId,
557+
clientSecret = storedOAuthSession.clientSecret,
558+
tokenCodeVerifier = "",
559+
state = "",
560+
tokenEndpoint = storedOAuthSession.tokenEndpoint,
561+
tokenAuthMethod = storedOAuthSession.tokenAuthMethod,
562+
tokenResponse = OAuthTokenResponse(
563+
accessToken = "",
564+
tokenType = "",
565+
expiresIn = null,
566+
refreshToken = storedOAuthSession.refreshToken,
567+
scope = null
568+
)
569+
)
570+
}
571+
wizard.model.goTo(WizardStep.CONNECT)
572+
return wizard
573+
} catch (ex: Exception) {
574+
errorBuffer.add(ex)
575+
} finally {
576+
firstRun = false
578577
}
579-
errorBuffer.clear()
580-
// and now reset the errors, otherwise we show it every time on the screen
581-
return setupWizardPage
582578
}
583-
return null
579+
580+
// Login flow.
581+
val setupWizardPage = CoderCliSetupWizardPage(
582+
context,
583+
settingsPage,
584+
visibilityState,
585+
onConnect = onConnect,
586+
onTokenRefreshed = ::onTokenRefreshed
587+
)
588+
setupWizardPage.model.goToFirst()
589+
// We might have navigated here due to a polling error.
590+
errorBuffer.forEach {
591+
setupWizardPage.notify("Error encountered", it)
592+
}
593+
errorBuffer.clear()
594+
return setupWizardPage
584595
}
585596

586597
/**

src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.coder.toolbox.util.getHeaders
88
import com.coder.toolbox.util.getOS
99
import com.coder.toolbox.util.sha1
1010
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.delay
1112
import kotlinx.coroutines.withContext
1213
import okhttp3.ResponseBody
1314
import retrofit2.Response
@@ -24,6 +25,7 @@ import java.nio.file.StandardOpenOption
2425
import java.util.zip.GZIPInputStream
2526
import kotlin.io.path.name
2627
import kotlin.io.path.notExists
28+
import kotlin.time.Duration.Companion.seconds
2729

2830
private val SUPPORTED_BIN_MIME_TYPES = listOf(
2931
"application/octet-stream",
@@ -73,6 +75,7 @@ class CoderDownloadService(
7375
}
7476
context.logger.info("Downloading binary to temporary $cliTempDst")
7577
response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable()
78+
delay(10.seconds)
7679
DownloadResult.Downloaded(remoteBinaryURL, cliTempDst)
7780
}
7881

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

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.cli.CoderCLIManager
55
import com.coder.toolbox.sdk.CoderRestClient
66
import com.coder.toolbox.views.state.CoderOAuthSessionContext
7-
import com.coder.toolbox.views.state.CoderSetupWizardState
7+
import com.coder.toolbox.views.state.WizardModel
88
import com.coder.toolbox.views.state.WizardStep
99
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1010
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
@@ -25,15 +25,18 @@ class CoderCliSetupWizardPage(
2525
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
2626
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null
2727
) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) {
28+
val model: WizardModel = WizardModel()
29+
2830
private val shouldAutoSetup = MutableStateFlow(initialAutoSetup)
2931
private val settingsAction = Action(context, "Settings") {
3032
context.ui.showUiPage(settingsPage)
3133
}
3234

33-
private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState)
34-
private val tokenStep = TokenStep(context)
35+
private val deploymentUrlStep = DeploymentUrlStep(context, model, visibilityState)
36+
private val tokenStep = TokenStep(context, model)
3537
private val connectStep = ConnectStep(
3638
context,
39+
model,
3740
shouldAutoLogin = shouldAutoSetup,
3841
jumpToMainPageOnError = jumpToMainPageOnError,
3942
visibilityState,
@@ -50,11 +53,10 @@ class CoderCliSetupWizardPage(
5053
override val fields: MutableStateFlow<List<UiField>> = MutableStateFlow(emptyList())
5154
override val actionButtons: MutableStateFlow<List<RunnableActionDescription>> = MutableStateFlow(emptyList())
5255

53-
5456
override fun beforeShow() {
5557
stateCollectJob?.cancel()
5658
stateCollectJob = context.cs.launch {
57-
CoderSetupWizardState.step.collect { step ->
59+
model.step.collect { step ->
5860
context.logger.info("Wizard step changed to $step")
5961
displaySteps()
6062
}
@@ -63,7 +65,7 @@ class CoderCliSetupWizardPage(
6365
}
6466

6567
private fun displaySteps() {
66-
when (CoderSetupWizardState.currentStep()) {
68+
when (model.currentStep()) {
6769
WizardStep.URL_REQUEST -> {
6870
fields.update {
6971
listOf(deploymentUrlStep.panel)
@@ -120,16 +122,19 @@ class CoderCliSetupWizardPage(
120122
}
121123
connectStep.onVisible()
122124
}
123-
124-
WizardStep.DONE -> {
125-
context.logger.info("Closing the Setup Wizard")
126-
stateCollectJob?.cancel()
127-
context.ui.hideUiPage(this)
128-
CoderSetupWizardState.goToFirstStep()
129-
}
130125
}
131126
}
132127

128+
/**
129+
* Cancels any in-flight work owned by this wizard. Called by the page router
130+
* when the wizard is being replaced (e.g. by a deep link to a different
131+
* deployment) so its connect job doesn't keep running and clobber the new one.
132+
*/
133+
fun dispose() {
134+
stateCollectJob?.cancel()
135+
connectStep.dispose()
136+
}
137+
133138
override fun afterHide() {
134139
stateCollectJob?.cancel()
135140
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ class ConnectStep(
142142
oauthSession?.let { session ->
143143
onTokenRefreshed?.invoke(client.url, session)
144144
}
145-
model.clearFormData()
146-
model.goToDone()
145+
// The provider's onConnect ran close() which clears the router; combined
146+
// with client now being non-null this drops the wizard from getOverrideUiPage.
147147
context.envPageManager.showPluginEnvironmentsPage()
148148
} catch (ex: CancellationException) {
149149
if (ex.message != USER_HIT_THE_BACK_BUTTON) {
@@ -208,4 +208,14 @@ class ConnectStep(
208208
handleNavigation()
209209
}
210210
}
211+
212+
/**
213+
* Cancels any in-flight connection without navigating. Used when the wizard
214+
* itself is being torn down by an external trigger (e.g. a deep link to a
215+
* different deployment).
216+
*/
217+
fun dispose() {
218+
signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON))
219+
signInJob = null
220+
}
211221
}

0 commit comments

Comments
 (0)