Skip to content

Commit 2eb4848

Browse files
authored
impl: visual text progress during Coder CLI downloading (#130)
This PR implements a mechanism to provide recurrent stats about the number of the KB and MB of Coder CLI downloaded.
1 parent 8eb08e9 commit 2eb4848

File tree

14 files changed

+166
-120
lines changed

14 files changed

+166
-120
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- visual text progress during Coder CLI downloading
8+
59
### Changed
610

711
- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import com.coder.toolbox.util.CoderProtocolHandler
99
import com.coder.toolbox.util.DialogUi
1010
import com.coder.toolbox.util.withPath
1111
import com.coder.toolbox.views.Action
12-
import com.coder.toolbox.views.AuthWizardPage
12+
import com.coder.toolbox.views.CoderCliSetupWizardPage
1313
import com.coder.toolbox.views.CoderSettingsPage
1414
import com.coder.toolbox.views.NewEnvironmentPage
15-
import com.coder.toolbox.views.state.AuthWizardState
15+
import com.coder.toolbox.views.state.CoderCliSetupWizardState
1616
import com.coder.toolbox.views.state.WizardStep
1717
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
1818
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
@@ -242,7 +242,7 @@ class CoderRemoteProvider(
242242
environments.value = LoadableState.Value(emptyList())
243243
isInitialized.update { false }
244244
client = null
245-
AuthWizardState.resetSteps()
245+
CoderCliSetupWizardState.resetSteps()
246246
}
247247

248248
override val svgIcon: SvgIcon =
@@ -301,7 +301,7 @@ class CoderRemoteProvider(
301301
*/
302302
override suspend fun handleUri(uri: URI) {
303303
linkHandler.handle(
304-
uri, shouldDoAutoLogin(),
304+
uri, shouldDoAutoSetup(),
305305
{
306306
coderHeaderPage.isBusyCreatingNewEnvironment.update {
307307
true
@@ -343,17 +343,17 @@ class CoderRemoteProvider(
343343
* list.
344344
*/
345345
override fun getOverrideUiPage(): UiPage? {
346-
// Show sign in page if we have not configured the client yet.
346+
// Show the setup page if we have not configured the client yet.
347347
if (client == null) {
348348
val errorBuffer = mutableListOf<Throwable>()
349-
// When coming back to the application, authenticate immediately.
350-
val autologin = shouldDoAutoLogin()
349+
// When coming back to the application, initializeSession immediately.
350+
val autoSetup = shouldDoAutoSetup()
351351
context.secrets.lastToken.let { lastToken ->
352352
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
353-
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
353+
if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
354354
try {
355-
AuthWizardState.goToStep(WizardStep.LOGIN)
356-
return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
355+
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
356+
return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
357357
} catch (ex: Exception) {
358358
errorBuffer.add(ex)
359359
}
@@ -363,18 +363,19 @@ class CoderRemoteProvider(
363363
firstRun = false
364364

365365
// Login flow.
366-
val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
366+
val setupWizardPage =
367+
CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
367368
// We might have navigated here due to a polling error.
368369
errorBuffer.forEach {
369-
authWizard.notify("Error encountered", it)
370+
setupWizardPage.notify("Error encountered", it)
370371
}
371372
// and now reset the errors, otherwise we show it every time on the screen
372-
return authWizard
373+
return setupWizardPage
373374
}
374375
return null
375376
}
376377

377-
private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true
378+
private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true
378379

379380
private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
380381
// Store the URL and token for use next time.

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import java.net.HttpURLConnection
3232
import java.net.URL
3333
import java.nio.file.Files
3434
import java.nio.file.Path
35-
import java.nio.file.StandardCopyOption
35+
import java.nio.file.StandardOpenOption
3636
import java.util.zip.GZIPInputStream
3737
import javax.net.ssl.HttpsURLConnection
3838

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

47+
private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..."
48+
4749
/**
4850
* Do as much as possible to get a valid, up-to-date CLI.
4951
*
@@ -60,6 +62,7 @@ fun ensureCLI(
6062
context: CoderToolboxContext,
6163
deploymentURL: URL,
6264
buildVersion: String,
65+
showTextProgress: (String) -> Unit
6366
): CoderCLIManager {
6467
val settings = context.settingsStore.readOnly()
6568
val cli = CoderCLIManager(deploymentURL, context.logger, settings)
@@ -76,9 +79,10 @@ fun ensureCLI(
7679

7780
// If downloads are enabled download the new version.
7881
if (settings.enableDownloads) {
79-
context.logger.info("Downloading Coder CLI...")
82+
context.logger.info(DOWNLOADING_CODER_CLI)
83+
showTextProgress(DOWNLOADING_CODER_CLI)
8084
try {
81-
cli.download()
85+
cli.download(buildVersion, showTextProgress)
8286
return cli
8387
} catch (e: java.nio.file.AccessDeniedException) {
8488
// Might be able to fall back to the data directory.
@@ -98,8 +102,9 @@ fun ensureCLI(
98102
}
99103

100104
if (settings.enableDownloads) {
101-
context.logger.info("Downloading Coder CLI...")
102-
dataCLI.download()
105+
context.logger.info(DOWNLOADING_CODER_CLI)
106+
showTextProgress(DOWNLOADING_CODER_CLI)
107+
dataCLI.download(buildVersion, showTextProgress)
103108
return dataCLI
104109
}
105110

@@ -137,7 +142,7 @@ class CoderCLIManager(
137142
/**
138143
* Download the CLI from the deployment if necessary.
139144
*/
140-
fun download(): Boolean {
145+
fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean {
141146
val eTag = getBinaryETag()
142147
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
143148
if (!settings.headerCommand.isNullOrBlank()) {
@@ -162,13 +167,27 @@ class CoderCLIManager(
162167
when (conn.responseCode) {
163168
HttpURLConnection.HTTP_OK -> {
164169
logger.info("Downloading binary to $localBinaryPath")
170+
Files.deleteIfExists(localBinaryPath)
165171
Files.createDirectories(localBinaryPath.parent)
166-
conn.inputStream.use {
167-
Files.copy(
168-
if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it,
169-
localBinaryPath,
170-
StandardCopyOption.REPLACE_EXISTING,
171-
)
172+
val outputStream = Files.newOutputStream(
173+
localBinaryPath,
174+
StandardOpenOption.CREATE,
175+
StandardOpenOption.TRUNCATE_EXISTING
176+
)
177+
val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream
178+
179+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
180+
var bytesRead: Int
181+
var totalRead = 0L
182+
183+
sourceStream.use { source ->
184+
outputStream.use { sink ->
185+
while (source.read(buffer).also { bytesRead = it } != -1) {
186+
sink.write(buffer, 0, bytesRead)
187+
totalRead += bytesRead
188+
showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded")
189+
}
190+
}
172191
}
173192
if (getOS() != OS.WINDOWS) {
174193
localBinaryPath.toFile().setExecutable(true)
@@ -178,6 +197,7 @@ class CoderCLIManager(
178197

179198
HttpURLConnection.HTTP_NOT_MODIFIED -> {
180199
logger.info("Using cached binary at $localBinaryPath")
200+
showTextProgress("Using cached binary")
181201
return false
182202
}
183203
}
@@ -190,6 +210,21 @@ class CoderCLIManager(
190210
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
191211
}
192212

213+
private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true)
214+
215+
fun Long.toHumanReadableSize(): String {
216+
if (this < 1024) return "$this B"
217+
218+
val kb = this / 1024.0
219+
if (kb < 1024) return String.format("%.1f KB", kb)
220+
221+
val mb = kb / 1024.0
222+
if (mb < 1024) return String.format("%.1f MB", mb)
223+
224+
val gb = mb / 1024.0
225+
return String.format("%.1f GB", gb)
226+
}
227+
193228
/**
194229
* Return the entity tag for the binary on disk, if any.
195230
*/
@@ -203,7 +238,7 @@ class CoderCLIManager(
203238
}
204239

205240
/**
206-
* Use the provided token to authenticate the CLI.
241+
* Use the provided token to initializeSession the CLI.
207242
*/
208243
fun login(token: String): String {
209244
logger.info("Storing CLI credentials in $coderConfigPath")

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,11 @@ open class CoderRestClient(
131131
}
132132

133133
/**
134-
* Authenticate and load information about the current user and the build
135-
* version.
134+
* Load information about the current user and the build version.
136135
*
137136
* @throws [APIResponseException].
138137
*/
139-
suspend fun authenticate(): User {
138+
suspend fun initializeSession(): User {
140139
me = me()
141140
buildVersion = buildInfo().version
142141
return me
@@ -149,7 +148,12 @@ open class CoderRestClient(
149148
suspend fun me(): User {
150149
val userResponse = retroRestClient.me()
151150
if (!userResponse.isSuccessful) {
152-
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
151+
throw APIResponseException(
152+
"initializeSession",
153+
url,
154+
userResponse.code(),
155+
userResponse.parseErrorBody(moshi)
156+
)
153157
}
154158

155159
return userResponse.body()!!

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds
2424
import kotlin.time.toJavaDuration
2525

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

2829
@Suppress("UnstableApiUsage")
2930
open class CoderProtocolHandler(
@@ -143,7 +144,7 @@ open class CoderProtocolHandler(
143144
if (settings.requireTokenAuth) token else null,
144145
PluginManager.pluginInfo.version
145146
)
146-
client.authenticate()
147+
client.initializeSession()
147148
return client
148149
}
149150

@@ -304,7 +305,8 @@ open class CoderProtocolHandler(
304305
val cli = ensureCLI(
305306
context,
306307
deploymentURL.toURL(),
307-
restClient.buildInfo().version
308+
restClient.buildInfo().version,
309+
noOpTextProgress
308310
)
309311

310312
// We only need to log in if we are using token-based auth.

src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt renamed to src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import com.coder.toolbox.cli.CoderCLIManager
55
import com.coder.toolbox.sdk.CoderRestClient
66
import com.coder.toolbox.sdk.ex.APIResponseException
77
import com.coder.toolbox.util.toURL
8-
import com.coder.toolbox.views.state.AuthContext
9-
import com.coder.toolbox.views.state.AuthWizardState
8+
import com.coder.toolbox.views.state.CoderCliSetupContext
9+
import com.coder.toolbox.views.state.CoderCliSetupWizardState
1010
import com.coder.toolbox.views.state.WizardStep
1111
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1212
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
@@ -16,26 +16,26 @@ import kotlinx.coroutines.flow.update
1616
import kotlinx.coroutines.launch
1717
import java.util.UUID
1818

19-
class AuthWizardPage(
19+
class CoderCliSetupWizardPage(
2020
private val context: CoderToolboxContext,
2121
private val settingsPage: CoderSettingsPage,
2222
private val visibilityState: MutableStateFlow<ProviderVisibilityState>,
23-
initialAutoLogin: Boolean = false,
23+
initialAutoSetup: Boolean = false,
2424
onConnect: suspend (
2525
client: CoderRestClient,
2626
cli: CoderCLIManager,
2727
) -> Unit,
28-
) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) {
29-
private val shouldAutoLogin = MutableStateFlow(initialAutoLogin)
28+
) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) {
29+
private val shouldAutoSetup = MutableStateFlow(initialAutoSetup)
3030
private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = {
3131
context.ui.showUiPage(settingsPage)
3232
})
3333

34-
private val signInStep = SignInStep(context, this::notify)
34+
private val deploymentUrlStep = DeploymentUrlStep(context, this::notify)
3535
private val tokenStep = TokenStep(context)
3636
private val connectStep = ConnectStep(
3737
context,
38-
shouldAutoLogin,
38+
shouldAutoSetup,
3939
this::notify,
4040
this::displaySteps,
4141
onConnect
@@ -50,9 +50,9 @@ class AuthWizardPage(
5050
private val errorBuffer = mutableListOf<Throwable>()
5151

5252
init {
53-
if (shouldAutoLogin.value) {
54-
AuthContext.url = context.secrets.lastDeploymentURL.toURL()
55-
AuthContext.token = context.secrets.lastToken
53+
if (shouldAutoSetup.value) {
54+
CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL()
55+
CoderCliSetupContext.token = context.secrets.lastToken
5656
}
5757
}
5858

@@ -67,22 +67,22 @@ class AuthWizardPage(
6767
}
6868

6969
private fun displaySteps() {
70-
when (AuthWizardState.currentStep()) {
70+
when (CoderCliSetupWizardState.currentStep()) {
7171
WizardStep.URL_REQUEST -> {
7272
fields.update {
73-
listOf(signInStep.panel)
73+
listOf(deploymentUrlStep.panel)
7474
}
7575
actionButtons.update {
7676
listOf(
77-
Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = {
78-
if (signInStep.onNext()) {
77+
Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = {
78+
if (deploymentUrlStep.onNext()) {
7979
displaySteps()
8080
}
8181
}),
8282
settingsAction
8383
)
8484
}
85-
signInStep.onVisible()
85+
deploymentUrlStep.onVisible()
8686
}
8787

8888
WizardStep.TOKEN_REQUEST -> {
@@ -106,7 +106,7 @@ class AuthWizardPage(
106106
tokenStep.onVisible()
107107
}
108108

109-
WizardStep.LOGIN -> {
109+
WizardStep.CONNECT -> {
110110
fields.update {
111111
listOf(connectStep.panel)
112112
}
@@ -115,7 +115,7 @@ class AuthWizardPage(
115115
settingsAction,
116116
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
117117
connectStep.onBack()
118-
shouldAutoLogin.update {
118+
shouldAutoSetup.update {
119119
false
120120
}
121121
displaySteps()
@@ -150,7 +150,7 @@ class AuthWizardPage(
150150
context.cs.launch {
151151
context.ui.showSnackbar(
152152
UUID.randomUUID().toString(),
153-
context.i18n.ptrl("Error encountered during authentication"),
153+
context.i18n.ptrl("Error encountered while setting up Coder"),
154154
context.i18n.pnotr(textError ?: ""),
155155
context.i18n.ptrl("Dismiss")
156156
)

0 commit comments

Comments
 (0)