From 86833187a9b3d750a9fc3d8ba63be8ba15449b71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:58:33 +0200 Subject: [PATCH 1/4] Changelog update - `v0.7.2` (#218) Current pull request contains patched `CHANGELOG.md` file for the `v0.7.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3dbee..8817ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.7.2 - 2025-11-03 + ### Changed - URI handling no longer waits for confirmation to use latest build if the provided build number is too old From 186630f00ce2811e6c8420e8921ff2f2617f3466 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 22:49:01 +0200 Subject: [PATCH 2/4] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.319 to 3.320 (#219) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.319 to 3.320.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.319&new-version=3.320)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e54161e..a40c643 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.319" +plugin-structure = "3.320" mockk = "1.14.6" detekt = "1.23.8" bouncycastle = "1.82" From 18bffe8bc14ff87469fc46b7d6c09b5c1c7f040b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 12 Nov 2025 23:54:55 +0200 Subject: [PATCH 3/4] impl: ability to application name as main page title (#220) Netflix would like the ability to use application name displayed in the dashboard as the main page title instead of the URL. This PR adds a new option `useAppNameAsTitle` that allows users to specify whether or not they want to use the application name visible in the dashboard as Tbx main tile instead of the URL. The default will remain the URL. Unlike previous settings added for Netflix this one is also configurable from the UI (Coder Settings page) so not only via settings.json file. This is an option that probably makes sense for more users. --- CHANGELOG.md | 4 +++ .../com/coder/toolbox/CoderRemoteProvider.kt | 26 +++++++++++++++---- .../com/coder/toolbox/sdk/CoderRestClient.kt | 24 ++++++++++++++++- .../coder/toolbox/sdk/v2/CoderV2RestFacade.kt | 7 +++++ .../coder/toolbox/sdk/v2/models/Appearance.kt | 9 +++++++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 6 +++++ .../coder/toolbox/store/CoderSettingsStore.kt | 5 ++++ .../com/coder/toolbox/store/StoreKeys.kt | 2 ++ .../coder/toolbox/views/CoderSettingsPage.kt | 14 +++++++++- .../resources/localization/defaultMessages.po | 3 +++ .../toolbox/util/CoderProtocolHandlerTest.kt | 2 +- 11 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8817ffb..35e430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- application name can now be displayed as the main title page instead of the URL + ## 0.7.2 - 2025-11-03 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 300f5a9..6084880 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -57,7 +57,6 @@ class CoderRemoteProvider( private val triggerSshConfig = Channel(Channel.CONFLATED) private val triggerProviderVisible = Channel(Channel.CONFLATED) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) private val dialogUi = DialogUi(context) // The REST client, if we are signed in @@ -65,8 +64,18 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { + client?.let { restClient -> + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + } + } private val visibilityState = MutableStateFlow( ProviderVisibilityState( applicationVisible = false, @@ -227,7 +236,7 @@ class CoderRemoteProvider( val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() context.desktop.browse( url - .replace("\$workspaceOwner", client?.me()?.username ?: "") + .replace("\$workspaceOwner", client?.me?.username ?: "") ) { context.ui.showErrorInfoPopup(it) } @@ -333,8 +342,11 @@ class CoderRemoteProvider( } context.logger.info("Starting initialization with the new settings") this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - + 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") @@ -421,7 +433,11 @@ class CoderRemoteProvider( context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") } environments.showLoadingMessage() - coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + } context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1ded07a..d4117db 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -45,6 +46,7 @@ open class CoderRestClient( lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() @@ -94,6 +96,7 @@ open class CoderRestClient( suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -101,7 +104,7 @@ open class CoderRestClient( * Retrieve the current user. * @throws [APIResponseException]. */ - suspend fun me(): User { + internal suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { throw APIResponseException( @@ -117,6 +120,25 @@ open class CoderRestClient( } } + /** + * Retrieves the visual dashboard configuration. + */ + internal suspend fun appearance(): Appearance { + val appearanceResponse = retroRestClient.appearance() + if (!appearanceResponse.isSuccessful) { + throw APIResponseException( + "initializeSession", + url, + appearanceResponse.code(), + appearanceResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(appearanceResponse.body()) { + "Successful response returned null body for visual dashboard configuration" + } + } + /** * Retrieves the available workspaces created by the user. * @throws [APIResponseException]. diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index adcaa6e..5e7fc13 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk.v2 +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -23,6 +24,12 @@ interface CoderV2RestFacade { @GET("api/v2/users/me") suspend fun me(): Response + /** + * Returns the configuration of the visual dashboard. + */ + @GET("api/v2/appearance") + suspend fun appearance(): Response + /** * Retrieves all workspaces the authenticated user has access to. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt new file mode 100644 index 0000000..0c8d830 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt @@ -0,0 +1,9 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Appearance( + @property:Json(name = "application_name") val applicationName: String +) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 8eed699..edf4801 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -19,6 +19,12 @@ interface ReadOnlyCoderSettings { */ val defaultURL: String + /** + * Whether to display the application name instead of the URL + * in the main screen. Defaults to URL + */ + val useAppNameAsTitle: Boolean + /** * Used to download the Coder CLI which is necessary to proxy SSH * connections. The If-None-Match header will be set to the SHA1 of the CLI diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index becdea0..ed8f009 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -38,6 +38,7 @@ class CoderSettingsStore( // Properties implementation override val lastDeploymentURL: String? get() = store[LAST_USED_URL] override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" + override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val disableSignatureVerification: Boolean @@ -165,6 +166,10 @@ class CoderSettingsStore( store[LAST_USED_URL] = url.toString() } + fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { + store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + } + fun updateBinarySource(source: String) { store[BINARY_SOURCE] = source } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index d38631a..bc46c4f 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -6,6 +6,8 @@ internal const val LAST_USED_URL = "lastDeploymentURL" internal const val DEFAULT_URL = "defaultURL" +internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle" + internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 5d5f115..b74b2d8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -28,7 +28,11 @@ import kotlinx.coroutines.launch * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConfig: Channel) : +class CoderSettingsPage( + private val context: CoderToolboxContext, + triggerSshConfig: Channel, + private val onSettingsClosed: () -> Unit +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() @@ -41,6 +45,8 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val useAppNameField = + CheckboxField(settings.useAppNameAsTitle, context.i18n.ptrl("Use app name as main page title instead of URL")) private val disableSignatureVerificationField = CheckboxField( settings.disableSignatureVerification, @@ -95,6 +101,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf listOf( binarySourceField, enableDownloadsField, + useAppNameField, binaryDirectoryField, enableBinaryDirectoryFallbackField, disableSignatureVerificationField, @@ -121,6 +128,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateUseAppNameAsTitle(useAppNameField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) @@ -164,6 +172,9 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableDownloadsField.checkedState.update { settings.enableDownloads } + useAppNameField.checkedState.update { + settings.useAppNameAsTitle + } signatureFallbackStrategyField.checkedState.update { settings.fallbackOnCoderForSignatures.isAllowed() } @@ -225,5 +236,6 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override fun afterHide() { visibilityUpdateJob.cancel() + onSettingsClosed() } } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 29351e3..16b6ed5 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -189,3 +189,6 @@ msgstr "" msgid "Workspace name" msgstr "" + +msgid "Use app name as main page title instead of URL" +msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 4a9ef88..1a84061 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -60,7 +60,7 @@ internal class CoderProtocolHandlerTest { private val protocolHandler = CoderProtocolHandler( context, DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED)), + CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), MutableStateFlow(false) ) From e24f564a22de60a7787a7c9ab21bda1fd531d264 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 21 Nov 2025 00:10:42 +0200 Subject: [PATCH 4/4] impl: start the workspace via Coder CLI (#221) Netflix uses custom MFA that requires CLI middleware to handle auth flow. The custom CLI implementation on their side intercepts 403 responses from the REST API, handles the MFA challenge, and retries the rest call again. The MFA challenge is handled only by the `start` and `ssh` actions. The remaining actions can go directly to the REST endpoints because of the custom header command that provides MFA tokens to the http calls. Both Gateway and VS Code extension delegate the start logic to the CLI, but not Toolbox which caused issues for the customer. This PR ports some of the work from Gateway in Coder Toolbox. --- CHANGELOG.md | 4 ++ .../coder/toolbox/CoderRemoteEnvironment.kt | 33 +++++++++++-- .../com/coder/toolbox/cli/CoderCLIManager.kt | 23 ++++++++- .../com/coder/toolbox/sdk/CoderRestClient.kt | 1 + .../toolbox/sdk/v2/models/WorkspaceBuild.kt | 49 +++++++++++++------ .../toolbox/util/CoderProtocolHandler.kt | 5 +- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 20 +++++++- 7 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e430f..40ad074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - application name can now be displayed as the main title page instead of the URL +### Changed + +- workspaces are now started with the help of the CLI + ## 0.7.2 - 2025-11-03 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index ff413c5..4b9c607 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -69,6 +71,7 @@ class CoderRemoteEnvironment( private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + private val startIsInProgress = AtomicBoolean(false) init { if (context.settingsStore.shouldAutoConnect(id)) { @@ -120,9 +123,29 @@ class CoderRemoteEnvironment( ) } else { actions.add(Action(context, "Start") { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - + try { + // needed in order to make sure Queuing is not overridden by the + // general polling loop with the `Stopped` state + startIsInProgress.set(true) + val startJob = context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) + } + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) { + state.update { + WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) + } + delay(1.seconds) + } + startIsInProgress.set(false) + // retrieve the status again and update the status + update(client.workspace(workspace.id), agent) + } finally { + startIsInProgress.set(false) + } } ) } @@ -241,6 +264,10 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (startIsInProgress.get()) { + context.logger.info("Skipping update for $id - workspace start is in progress") + return + } this.workspace = workspace this.agent = agent wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 3c0aedd..eb289af 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -125,6 +125,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSsh: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -304,6 +305,25 @@ class CoderCLIManager( ) } + /** + * Start a workspace. Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + "$workspaceOwner/$workspaceName" + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } + /** * Configure SSH to use this binary. * @@ -569,7 +589,8 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), - version >= SemVer(2, 19, 0), + wildcardSsh = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index d4117db..7023c76 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -241,6 +241,7 @@ open class CoderRestClient( /** * @throws [APIResponseException]. */ + @Deprecated(message = "This operation needs to be delegated to the CLI") suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest( null, diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt index 2c5767e..a7752a8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt @@ -10,20 +10,41 @@ import java.util.UUID */ @JsonClass(generateAdapter = true) data class WorkspaceBuild( - @Json(name = "template_version_id") val templateVersionID: UUID, - @Json(name = "resources") val resources: List, - @Json(name = "status") val status: WorkspaceStatus, + @property:Json(name = "template_version_id") val templateVersionID: UUID, + @property:Json(name = "resources") val resources: List, + @property:Json(name = "status") val status: WorkspaceStatus, ) enum class WorkspaceStatus { - @Json(name = "pending") PENDING, - @Json(name = "starting") STARTING, - @Json(name = "running") RUNNING, - @Json(name = "stopping") STOPPING, - @Json(name = "stopped") STOPPED, - @Json(name = "failed") FAILED, - @Json(name = "canceling") CANCELING, - @Json(name = "canceled") CANCELED, - @Json(name = "deleting") DELETING, - @Json(name = "deleted") DELETED, -} + @Json(name = "pending") + PENDING, + + @Json(name = "starting") + STARTING, + + @Json(name = "running") + RUNNING, + + @Json(name = "stopping") + STOPPING, + + @Json(name = "stopped") + STOPPED, + + @Json(name = "failed") + FAILED, + + @Json(name = "canceling") + CANCELING, + + @Json(name = "canceled") + CANCELED, + + @Json(name = "deleting") + DELETING, + + @Json(name = "deleted") + DELETED; + + fun isNotStarted(): Boolean = this != STARTING && this != RUNNING +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3dec81b..8e4dfbb 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -84,7 +84,7 @@ open class CoderProtocolHandler( } reInitialize(restClient, cli) context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + 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) @@ -180,6 +180,7 @@ open class CoderProtocolHandler( private suspend fun prepareWorkspace( workspace: Workspace, restClient: CoderRestClient, + cli: CoderCLIManager, workspaceName: String, deploymentURL: String ): Boolean { @@ -207,7 +208,7 @@ open class CoderProtocolHandler( if (workspace.outdated) { restClient.updateWorkspace(workspace) } else { - restClient.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } } catch (e: Exception) { context.logAndShowError( diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7f5c831..74caf65 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -976,8 +976,24 @@ internal class CoderCLIManagerTest { val tests = listOf( Pair("2.5.0", Features(true)), - Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true, true)), + Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)), + Pair( + "2.25.0", + Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), + Pair( + "4.9.0", Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), )