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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy