Skip to content
Draft
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

- workspaces are now started with the help of the CLI
- simplified URI handling when the same deployment URL is already opened

## 0.7.2 - 2025-11-03

Expand Down
12 changes: 4 additions & 8 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,8 @@ class CoderRemoteEnvironment(

private fun updateStatus(status: WorkspaceAndAgentStatus) {
environmentStatus = status
context.cs.launch(CoroutineName("Workspace Status Updater")) {
state.update {
environmentStatus.toRemoteEnvironmentState(context)
}
state.update {
environmentStatus.toRemoteEnvironmentState(context)
}
context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}")
}
Expand Down Expand Up @@ -312,10 +310,8 @@ class CoderRemoteEnvironment(
*/
fun startSshConnection(): Boolean {
if (environmentStatus.ready() && !isConnected.value) {
context.cs.launch(CoroutineName("SSH Connection Trigger")) {
connectionRequest.update {
true
}
connectionRequest.update {
true
}
return true
}
Expand Down
85 changes: 60 additions & 25 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.coder.toolbox

import com.coder.toolbox.browser.browse
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.plugin.PluginManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
Expand Down Expand Up @@ -37,6 +38,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import java.net.URI
import java.net.URL
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
Expand Down Expand Up @@ -254,6 +256,16 @@ class CoderRemoteProvider(
* Also called as part of our own logout.
*/
override fun close() {
softClose()
client = null
lastEnvironments.clear()
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
CoderCliSetupWizardState.goToFirstStep()
context.logger.info("Coder plugin is now closed")
}

private fun softClose() {
pollJob?.let {
it.cancel()
context.logger.info("Cancelled workspace poll job ${pollJob.toString()}")
Expand All @@ -262,12 +274,6 @@ class CoderRemoteProvider(
it.close()
context.logger.info("REST API client closed and resources released")
}
client = null
lastEnvironments.clear()
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
CoderCliSetupWizardState.goToFirstStep()
context.logger.info("Coder plugin is now closed")
}

override val svgIcon: SvgIcon =
Expand Down Expand Up @@ -333,25 +339,11 @@ class CoderRemoteProvider(
try {
linkHandler.handle(
uri,
shouldDoAutoSetup()
) { restClient, cli ->
context.logger.info("Stopping workspace polling and de-initializing resources")
close()
isInitialized.update {
false
}
context.logger.info("Starting initialization with the new settings")
this@CoderRemoteProvider.client = restClient
if (context.settingsStore.useAppNameAsTitle) {
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName))
} else {
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString()))
}
environments.showLoadingMessage()
pollJob = poll(restClient, cli)
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri")
isInitialized.waitForTrue()
}
client?.url,
shouldDoAutoSetup(),
::refreshSession,
::performReLogin
)
} catch (ex: Exception) {
val textError = if (ex is APIResponseException) {
if (!ex.reason.isNullOrBlank()) {
Expand All @@ -366,6 +358,49 @@ class CoderRemoteProvider(
}
}

private suspend fun refreshSession(url: URL, token: String): Pair<CoderRestClient, CoderCLIManager> {
coderHeaderPage.isBusyCreatingNewEnvironment.update { true }
try {
context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token")
softClose()
val restClient = CoderRestClient(
context,
url,
token,
PluginManager.pluginInfo.version,
).apply { initializeSession() }
val cli = CoderCLIManager(context, url).apply {
login(token)
}
this.client = restClient
pollJob = poll(restClient, cli)
triggerProviderVisible.send(true)
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
return restClient to cli
} finally {
coderHeaderPage.isBusyCreatingNewEnvironment.update { false }
}
}

private suspend fun performReLogin(restClient: CoderRestClient, cli: CoderCLIManager) {
context.logger.info("Stopping workspace polling and de-initializing resources")
close()
isInitialized.update {
false
}
context.logger.info("Starting initialization with the new settings")
this@CoderRemoteProvider.client = restClient
if (context.settingsStore.useAppNameAsTitle) {
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName))
} else {
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString()))
}
environments.showLoadingMessage()
pollJob = poll(restClient, cli)
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
isInitialized.waitForTrue()
}

/**
* Return the sign-in page if we do not have a valid client.

Expand Down
137 changes: 81 additions & 56 deletions src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout
import java.net.URI
import java.net.URL
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
Expand Down Expand Up @@ -52,8 +53,10 @@ open class CoderProtocolHandler(
*/
suspend fun handle(
uri: URI,
currentUrl: URL?,
shouldWaitForAutoLogin: Boolean,
reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit
refreshSession: suspend (URL, String) -> Pair<CoderRestClient, CoderCLIManager>,
performLogin: suspend (CoderRestClient, CoderCLIManager) -> Unit
) {
val params = uri.toQueryParameters()
if (params.isEmpty()) {
Expand All @@ -72,65 +75,56 @@ open class CoderProtocolHandler(
val deploymentURL = resolveDeploymentUrl(params) ?: return
val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return
val workspaceName = resolveWorkspaceName(params) ?: return

suspend fun onConnect(
restClient: CoderRestClient,
cli: CoderCLIManager
) {
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
if (workspace == null) {
context.envPageManager.showPluginEnvironmentsPage()
return
if (deploymentURL.toURL().toURI().normalize() == currentUrl?.toURI()?.normalize()) {
if (context.settingsStore.requiresTokenAuth) {
token?.let {
val (restClient, cli) = refreshSession(currentUrl, it)
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
if (workspace != null) {
connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params)
}
}
}
reInitialize(restClient, cli)
context.envPageManager.showPluginEnvironmentsPage()
if (!prepareWorkspace(workspace, restClient, cli, 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.
val agent: WorkspaceAgent = resolveAgent(
params,
restClient.workspace(workspace.id)
) ?: return
if (!ensureAgentIsReady(workspace, agent)) return
delay(2.seconds)
val environmentId = "${workspace.name}.${agent.name}"
context.showEnvironmentPage(environmentId)

val productCode = params.ideProductCode()
val buildNumber = params.ideBuildNumber()
val projectFolder = params.projectFolder()

if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) {
launchIde(environmentId, productCode, buildNumber, projectFolder)
} else {
suspend fun onConnect(
restClient: CoderRestClient,
cli: CoderCLIManager
) {
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
if (workspace == null) {
context.envPageManager.showPluginEnvironmentsPage()
return
}
performLogin(restClient, cli)
context.envPageManager.showPluginEnvironmentsPage()
connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params)
}
}

CoderCliSetupContext.apply {
url = deploymentURL.toURL()
CoderCliSetupContext.token = token
}
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)

// If Toolbox is already opened and URI is executed the setup page
// from below is never called. I tried a couple of things, including
// yielding the coroutine - but it seems to be of no help. What works
// delaying the coroutine for 66 - to 100 milliseconds, these numbers
// were determined by trial and error.
// The only explanation that I have is that inspecting the TBX bytecode it seems the
// UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events
// and a drop oldest strategy. For some reason it seems that the UI collector
// is not yet active, causing the event to be lost unless we wait > 66 ms.
// I think this delay ensures the collector is ready before processEvent() is called.
delay(100.milliseconds)
context.ui.showUiPage(
CoderCliSetupWizardPage(
context, settingsPage, visibilityState, true,
jumpToMainPageOnError = true,
onConnect = ::onConnect
CoderCliSetupContext.apply {
url = deploymentURL.toURL()
CoderCliSetupContext.token = token
}
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)

// If Toolbox is already opened and URI is executed the setup page
// from below is never called. I tried a couple of things, including
// yielding the coroutine - but it seems to be of no help. What works
// delaying the coroutine for 66 - to 100 milliseconds, these numbers
// were determined by trial and error.
// The only explanation that I have is that inspecting the TBX bytecode it seems the
// UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events
// and a drop oldest strategy. For some reason it seems that the UI collector
// is not yet active, causing the event to be lost unless we wait > 66 ms.
// I think this delay ensures the collector is ready before processEvent() is called.
delay(100.milliseconds)
context.ui.showUiPage(
CoderCliSetupWizardPage(
context, settingsPage, visibilityState, true,
jumpToMainPageOnError = true,
onConnect = ::onConnect
)
)
)
}
}

private suspend fun resolveDeploymentUrl(params: Map<String, String>): String? {
Expand Down Expand Up @@ -434,6 +428,37 @@ open class CoderProtocolHandler(
}
}

private suspend fun connectToWorkspace(
workspace: Workspace,
restClient: CoderRestClient,
cli: CoderCLIManager,
workspaceName: String,
deploymentURL: String,
params: Map<String, String>
) {
if (!prepareWorkspace(workspace, restClient, cli, 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.
val agent: WorkspaceAgent = resolveAgent(
params,
restClient.workspace(workspace.id)
) ?: return
if (!ensureAgentIsReady(workspace, agent)) return
delay(2.seconds)
val environmentId = "${workspace.name}.${agent.name}"
context.showEnvironmentPage(environmentId)

val productCode = params.ideProductCode()
val buildNumber = params.ideBuildNumber()
val projectFolder = params.projectFolder()

if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) {
launchIde(environmentId, productCode, buildNumber, projectFolder)
}
}

private suspend fun askUrl(): String? {
context.popupPluginMainPage()
return dialogUi.ask(
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ abstract class CoderPage(
}
}

override val isBusyCreatingNewEnvironment: MutableStateFlow<Boolean> = MutableStateFlow(false)

/**
* Return the icon, if showing one.
*
Expand Down
Loading