Skip to content

impl: visual text progress during Coder CLI downloading #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 19, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- visual text progress during Coder CLI downloading

### Changed

- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically
Expand Down
29 changes: 15 additions & 14 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ 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.CoderCliSetupWizardPage
import com.coder.toolbox.views.CoderSettingsPage
import com.coder.toolbox.views.NewEnvironmentPage
import com.coder.toolbox.views.state.AuthWizardState
import com.coder.toolbox.views.state.CoderCliSetupWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
Expand Down Expand Up @@ -242,7 +242,7 @@ class CoderRemoteProvider(
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
client = null
AuthWizardState.resetSteps()
CoderCliSetupWizardState.resetSteps()
}

override val svgIcon: SvgIcon =
Expand Down Expand Up @@ -301,7 +301,7 @@ class CoderRemoteProvider(
*/
override suspend fun handleUri(uri: URI) {
linkHandler.handle(
uri, shouldDoAutoLogin(),
uri, shouldDoAutoSetup(),
{
coderHeaderPage.isBusyCreatingNewEnvironment.update {
true
Expand Down Expand Up @@ -343,17 +343,17 @@ class CoderRemoteProvider(
* list.
*/
override fun getOverrideUiPage(): UiPage? {
// Show sign in page if we have not configured the client yet.
// Show the setup page if we have not configured the client yet.
if (client == null) {
val errorBuffer = mutableListOf<Throwable>()
// When coming back to the application, authenticate immediately.
val autologin = shouldDoAutoLogin()
// When coming back to the application, initializeSession immediately.
val autoSetup = shouldDoAutoSetup()
context.secrets.lastToken.let { lastToken ->
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
try {
AuthWizardState.goToStep(WizardStep.LOGIN)
return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
} catch (ex: Exception) {
errorBuffer.add(ex)
}
Expand All @@ -363,18 +363,19 @@ class CoderRemoteProvider(
firstRun = false

// Login flow.
val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
val setupWizardPage =
CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
// We might have navigated here due to a polling error.
errorBuffer.forEach {
authWizard.notify("Error encountered", it)
setupWizardPage.notify("Error encountered", it)
}
// and now reset the errors, otherwise we show it every time on the screen
return authWizard
return setupWizardPage
}
return null
}

private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true
private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true

private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
// Store the URL and token for use next time.
Expand Down
61 changes: 48 additions & 13 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import javax.net.ssl.HttpsURLConnection

Expand All @@ -44,6 +44,8 @@ internal data class Version(
@Json(name = "version") val version: String,
)

private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..."

/**
* Do as much as possible to get a valid, up-to-date CLI.
*
Expand All @@ -60,6 +62,7 @@ fun ensureCLI(
context: CoderToolboxContext,
deploymentURL: URL,
buildVersion: String,
showTextProgress: (String) -> Unit
): CoderCLIManager {
val settings = context.settingsStore.readOnly()
val cli = CoderCLIManager(deploymentURL, context.logger, settings)
Expand All @@ -76,9 +79,10 @@ fun ensureCLI(

// If downloads are enabled download the new version.
if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
try {
cli.download()
cli.download(buildVersion, showTextProgress)
return cli
} catch (e: java.nio.file.AccessDeniedException) {
// Might be able to fall back to the data directory.
Expand All @@ -98,8 +102,9 @@ fun ensureCLI(
}

if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
dataCLI.download()
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
dataCLI.download(buildVersion, showTextProgress)
return dataCLI
}

Expand Down Expand Up @@ -137,7 +142,7 @@ class CoderCLIManager(
/**
* Download the CLI from the deployment if necessary.
*/
fun download(): Boolean {
fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean {
val eTag = getBinaryETag()
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
if (!settings.headerCommand.isNullOrBlank()) {
Expand All @@ -162,13 +167,27 @@ class CoderCLIManager(
when (conn.responseCode) {
HttpURLConnection.HTTP_OK -> {
logger.info("Downloading binary to $localBinaryPath")
Files.deleteIfExists(localBinaryPath)
Files.createDirectories(localBinaryPath.parent)
conn.inputStream.use {
Files.copy(
if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it,
localBinaryPath,
StandardCopyOption.REPLACE_EXISTING,
)
val outputStream = Files.newOutputStream(
localBinaryPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L

sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded")
}
}
}
if (getOS() != OS.WINDOWS) {
localBinaryPath.toFile().setExecutable(true)
Expand All @@ -178,6 +197,7 @@ class CoderCLIManager(

HttpURLConnection.HTTP_NOT_MODIFIED -> {
logger.info("Using cached binary at $localBinaryPath")
showTextProgress("Using cached binary")
return false
}
}
Expand All @@ -190,6 +210,21 @@ class CoderCLIManager(
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
}

private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true)

fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

/**
* Return the entity tag for the binary on disk, if any.
*/
Expand All @@ -203,7 +238,7 @@ class CoderCLIManager(
}

/**
* Use the provided token to authenticate the CLI.
* Use the provided token to initializeSession the CLI.
*/
fun login(token: String): String {
logger.info("Storing CLI credentials in $coderConfigPath")
Expand Down
12 changes: 8 additions & 4 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,11 @@ open class CoderRestClient(
}

/**
* Authenticate and load information about the current user and the build
* version.
* Load information about the current user and the build version.
*
* @throws [APIResponseException].
*/
suspend fun authenticate(): User {
suspend fun initializeSession(): User {
me = me()
buildVersion = buildInfo().version
return me
Expand All @@ -149,7 +148,12 @@ open class CoderRestClient(
suspend fun me(): User {
val userResponse = retroRestClient.me()
if (!userResponse.isSuccessful) {
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
throw APIResponseException(
"initializeSession",
url,
userResponse.code(),
userResponse.parseErrorBody(moshi)
)
}

return userResponse.body()!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI"
private val noOpTextProgress: (String) -> Unit = { _ -> }

@Suppress("UnstableApiUsage")
open class CoderProtocolHandler(
Expand Down Expand Up @@ -143,7 +144,7 @@ open class CoderProtocolHandler(
if (settings.requireTokenAuth) token else null,
PluginManager.pluginInfo.version
)
client.authenticate()
client.initializeSession()
return client
}

Expand Down Expand Up @@ -304,7 +305,8 @@ open class CoderProtocolHandler(
val cli = ensureCLI(
context,
deploymentURL.toURL(),
restClient.buildInfo().version
restClient.buildInfo().version,
noOpTextProgress
)

// We only need to log in if we are using token-based auth.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ 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.CoderCliSetupContext
import com.coder.toolbox.views.state.CoderCliSetupWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
Expand All @@ -16,26 +16,26 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID

class AuthWizardPage(
class CoderCliSetupWizardPage(
private val context: CoderToolboxContext,
private val settingsPage: CoderSettingsPage,
private val visibilityState: MutableStateFlow<ProviderVisibilityState>,
initialAutoLogin: Boolean = false,
initialAutoSetup: Boolean = false,
onConnect: suspend (
client: CoderRestClient,
cli: CoderCLIManager,
) -> Unit,
) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) {
private val shouldAutoLogin = MutableStateFlow(initialAutoLogin)
) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) {
private val shouldAutoSetup = MutableStateFlow(initialAutoSetup)
private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = {
context.ui.showUiPage(settingsPage)
})

private val signInStep = SignInStep(context, this::notify)
private val deploymentUrlStep = DeploymentUrlStep(context, this::notify)
private val tokenStep = TokenStep(context)
private val connectStep = ConnectStep(
context,
shouldAutoLogin,
shouldAutoSetup,
this::notify,
this::displaySteps,
onConnect
Expand All @@ -50,9 +50,9 @@ class AuthWizardPage(
private val errorBuffer = mutableListOf<Throwable>()

init {
if (shouldAutoLogin.value) {
AuthContext.url = context.secrets.lastDeploymentURL.toURL()
AuthContext.token = context.secrets.lastToken
if (shouldAutoSetup.value) {
CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL()
CoderCliSetupContext.token = context.secrets.lastToken
}
}

Expand All @@ -67,22 +67,22 @@ class AuthWizardPage(
}

private fun displaySteps() {
when (AuthWizardState.currentStep()) {
when (CoderCliSetupWizardState.currentStep()) {
WizardStep.URL_REQUEST -> {
fields.update {
listOf(signInStep.panel)
listOf(deploymentUrlStep.panel)
}
actionButtons.update {
listOf(
Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = {
if (signInStep.onNext()) {
Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = {
if (deploymentUrlStep.onNext()) {
displaySteps()
}
}),
settingsAction
)
}
signInStep.onVisible()
deploymentUrlStep.onVisible()
}

WizardStep.TOKEN_REQUEST -> {
Expand All @@ -106,7 +106,7 @@ class AuthWizardPage(
tokenStep.onVisible()
}

WizardStep.LOGIN -> {
WizardStep.CONNECT -> {
fields.update {
listOf(connectStep.panel)
}
Expand All @@ -115,7 +115,7 @@ class AuthWizardPage(
settingsAction,
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
connectStep.onBack()
shouldAutoLogin.update {
shouldAutoSetup.update {
false
}
displaySteps()
Expand Down Expand Up @@ -150,7 +150,7 @@ class AuthWizardPage(
context.cs.launch {
context.ui.showSnackbar(
UUID.randomUUID().toString(),
context.i18n.ptrl("Error encountered during authentication"),
context.i18n.ptrl("Error encountered while setting up Coder"),
context.i18n.pnotr(textError ?: ""),
context.i18n.ptrl("Dismiss")
)
Expand Down
Loading
Loading