Skip to content

Commit afdc641

Browse files
committed
impl: visual text progress during Coder CLI downloading
This PR implements a mechanism to provide recurrent stats about the number of the KB and MB of Coder CLI downloaded.
1 parent 01651f0 commit afdc641

File tree

5 files changed

+80
-33
lines changed

5 files changed

+80
-33
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/cli/CoderCLIManager.kt

Lines changed: 46 additions & 12 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(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(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(showTextProgress: (String) -> Unit): Boolean {
141146
val eTag = getBinaryETag()
142147
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
143148
if (!settings.headerCommand.isNullOrBlank()) {
@@ -163,12 +168,25 @@ class CoderCLIManager(
163168
HttpURLConnection.HTTP_OK -> {
164169
logger.info("Downloading binary to $localBinaryPath")
165170
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-
)
171+
val outputStream = Files.newOutputStream(
172+
localBinaryPath,
173+
StandardOpenOption.CREATE,
174+
StandardOpenOption.TRUNCATE_EXISTING
175+
)
176+
val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream
177+
178+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
179+
var bytesRead: Int
180+
var totalRead = 0L
181+
182+
sourceStream.use { source ->
183+
outputStream.use { sink ->
184+
while (source.read(buffer).also { bytesRead = it } != -1) {
185+
sink.write(buffer, 0, bytesRead)
186+
totalRead += bytesRead
187+
showTextProgress("Downloaded ${totalRead.toHumanReadableSize()}...")
188+
}
189+
}
172190
}
173191
if (getOS() != OS.WINDOWS) {
174192
localBinaryPath.toFile().setExecutable(true)
@@ -178,6 +196,7 @@ class CoderCLIManager(
178196

179197
HttpURLConnection.HTTP_NOT_MODIFIED -> {
180198
logger.info("Using cached binary at $localBinaryPath")
199+
showTextProgress("Using cached binary")
181200
return false
182201
}
183202
}
@@ -190,6 +209,21 @@ class CoderCLIManager(
190209
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
191210
}
192211

212+
private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true)
213+
214+
fun Long.toHumanReadableSize(): String {
215+
if (this < 1024) return "$this B"
216+
217+
val kb = this / 1024.0
218+
if (kb < 1024) return String.format("%.1f KB", kb)
219+
220+
val mb = kb / 1024.0
221+
if (mb < 1024) return String.format("%.1f MB", mb)
222+
223+
val gb = mb / 1024.0
224+
return String.format("%.1f GB", gb)
225+
}
226+
193227
/**
194228
* Return the entity tag for the binary on disk, if any.
195229
*/

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

Lines changed: 3 additions & 1 deletion
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(
@@ -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/ConnectStep.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,16 @@ class ConnectStep(
8686
// allows interleaving with the back/cancel action
8787
yield()
8888
client.initializeSession()
89-
statusField.textState.update { (context.i18n.ptrl("Checking Coder binary...")) }
90-
val cli = ensureCLI(context, client.url, client.buildVersion)
89+
statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) }
90+
val cli = ensureCLI(
91+
context, client.url,
92+
client.buildVersion
93+
) { progress ->
94+
statusField.textState.update { (context.i18n.pnotr(progress)) }
95+
}
9196
// We only need to log in if we are using token-based auth.
9297
if (client.token != null) {
93-
statusField.textState.update { (context.i18n.ptrl("Configuring CLI...")) }
98+
statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) }
9499
// allows interleaving with the back/cancel action
95100
yield()
96101
cli.login(client.token)

src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import kotlin.test.assertFalse
6262
import kotlin.test.assertNotEquals
6363
import kotlin.test.assertTrue
6464

65+
private val noOpTextProgress: (String) -> Unit = { _ -> }
66+
6567
internal class CoderCLIManagerTest {
6668
private val context = CoderToolboxContext(
6769
mockk<ToolboxUi>(),
@@ -145,7 +147,7 @@ internal class CoderCLIManagerTest {
145147
val ex =
146148
assertFailsWith(
147149
exceptionClass = ResponseException::class,
148-
block = { ccm.download() },
150+
block = { ccm.download(noOpTextProgress) },
149151
)
150152
assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code)
151153

@@ -200,7 +202,7 @@ internal class CoderCLIManagerTest {
200202

201203
assertFailsWith(
202204
exceptionClass = AccessDeniedException::class,
203-
block = { ccm.download() },
205+
block = { ccm.download(noOpTextProgress) },
204206
)
205207

206208
srv.stop(0)
@@ -229,11 +231,11 @@ internal class CoderCLIManagerTest {
229231
).readOnly(),
230232
)
231233

232-
assertTrue(ccm.download())
234+
assertTrue(ccm.download(noOpTextProgress))
233235
assertDoesNotThrow { ccm.version() }
234236

235237
// It should skip the second attempt.
236-
assertFalse(ccm.download())
238+
assertFalse(ccm.download(noOpTextProgress))
237239

238240
// Make sure login failures propagate.
239241
assertFailsWith(
@@ -258,11 +260,11 @@ internal class CoderCLIManagerTest {
258260
).readOnly(),
259261
)
260262

261-
assertEquals(true, ccm.download())
263+
assertEquals(true, ccm.download(noOpTextProgress))
262264
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
263265

264266
// It should skip the second attempt.
265-
assertEquals(false, ccm.download())
267+
assertEquals(false, ccm.download(noOpTextProgress))
266268

267269
// Should use the source override.
268270
ccm = CoderCLIManager(
@@ -278,7 +280,7 @@ internal class CoderCLIManagerTest {
278280
).readOnly(),
279281
)
280282

281-
assertEquals(true, ccm.download())
283+
assertEquals(true, ccm.download(noOpTextProgress))
282284
assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0")
283285

284286
srv.stop(0)
@@ -326,7 +328,7 @@ internal class CoderCLIManagerTest {
326328
assertEquals("cli", ccm.localBinaryPath.toFile().readText())
327329
assertEquals(0, ccm.localBinaryPath.toFile().lastModified())
328330

329-
assertTrue(ccm.download())
331+
assertTrue(ccm.download(noOpTextProgress))
330332

331333
assertNotEquals("cli", ccm.localBinaryPath.toFile().readText())
332334
assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified())
@@ -351,8 +353,8 @@ internal class CoderCLIManagerTest {
351353
val ccm1 = CoderCLIManager(url1, context.logger, settings)
352354
val ccm2 = CoderCLIManager(url2, context.logger, settings)
353355

354-
assertTrue(ccm1.download())
355-
assertTrue(ccm2.download())
356+
assertTrue(ccm1.download(noOpTextProgress))
357+
assertTrue(ccm2.download(noOpTextProgress))
356358

357359
srv1.stop(0)
358360
srv2.stop(0)
@@ -883,12 +885,12 @@ internal class CoderCLIManagerTest {
883885
Result.ERROR -> {
884886
assertFailsWith(
885887
exceptionClass = AccessDeniedException::class,
886-
block = { ensureCLI(localContext, url, it.buildVersion) },
888+
block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) },
887889
)
888890
}
889891

890892
Result.NONE -> {
891-
val ccm = ensureCLI(localContext, url, it.buildVersion)
893+
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
892894
assertEquals(settings.binPath(url), ccm.localBinaryPath)
893895
assertFailsWith(
894896
exceptionClass = ProcessInitException::class,
@@ -897,25 +899,25 @@ internal class CoderCLIManagerTest {
897899
}
898900

899901
Result.DL_BIN -> {
900-
val ccm = ensureCLI(localContext, url, it.buildVersion)
902+
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
901903
assertEquals(settings.binPath(url), ccm.localBinaryPath)
902904
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
903905
}
904906

905907
Result.DL_DATA -> {
906-
val ccm = ensureCLI(localContext, url, it.buildVersion)
908+
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
907909
assertEquals(settings.binPath(url, true), ccm.localBinaryPath)
908910
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
909911
}
910912

911913
Result.USE_BIN -> {
912-
val ccm = ensureCLI(localContext, url, it.buildVersion)
914+
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
913915
assertEquals(settings.binPath(url), ccm.localBinaryPath)
914916
assertEquals(SemVer.parse(it.version ?: ""), ccm.version())
915917
}
916918

917919
Result.USE_DATA -> {
918-
val ccm = ensureCLI(localContext, url, it.buildVersion)
920+
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
919921
assertEquals(settings.binPath(url, true), ccm.localBinaryPath)
920922
assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version())
921923
}
@@ -955,7 +957,7 @@ internal class CoderCLIManagerTest {
955957
context.logger,
956958
).readOnly(),
957959
)
958-
assertEquals(true, ccm.download())
960+
assertEquals(true, ccm.download(noOpTextProgress))
959961
assertEquals(it.second, ccm.features, "version: ${it.first}")
960962

961963
srv.stop(0)

0 commit comments

Comments
 (0)