diff --git a/build.gradle.kts b/build.gradle.kts index a541a15..e0b1b04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,16 @@ +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter import com.github.jk1.license.render.JsonReportRenderer +import com.jetbrains.plugin.structure.toolbox.ToolboxMeta +import com.jetbrains.plugin.structure.toolbox.ToolboxPluginDescriptor import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.nio.file.Path +import kotlin.io.path.createDirectories import kotlin.io.path.div +import kotlin.io.path.writeText plugins { alias(libs.plugins.kotlin) @@ -14,23 +20,31 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.gradle.wrapper) alias(libs.plugins.changelog) + alias(libs.plugins.gettext) } -buildscript { - dependencies { - classpath(libs.marketplace.client) - } -} repositories { mavenCentral() maven("https://packages.jetbrains.team/maven/p/tbx/toolbox-api") } +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath(libs.marketplace.client) + classpath(libs.plugin.structure) + } +} + jvmWrapper { unixJvmInstallDir = "jvm" winJvmInstallDir = "jvm" - linuxAarch64JvmUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.5-linux-aarch64-b631.28.tar.gz" + linuxAarch64JvmUrl = + "https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.5-linux-aarch64-b631.28.tar.gz" linuxX64JvmUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.5-linux-x64-b631.28.tar.gz" macAarch64JvmUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.5-osx-aarch64-b631.28.tar.gz" macX64JvmUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.5-osx-x64-b631.28.tar.gz" @@ -39,9 +53,8 @@ jvmWrapper { dependencies { compileOnly(libs.bundles.toolbox.plugin.api) - implementation(libs.slf4j) - implementation(libs.bundles.serialization) - implementation(libs.coroutines.core) + compileOnly(libs.bundles.serialization) + compileOnly(libs.coroutines.core) implementation(libs.okhttp) implementation(libs.exec) implementation(libs.moshi) @@ -49,14 +62,34 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.moshi) testImplementation(kotlin("test")) + testImplementation(libs.mokk) + testImplementation(libs.bundles.toolbox.plugin.api) } -val pluginId = properties("group") -val pluginName = properties("name") -val pluginVersion = properties("version") +val extension = ExtensionJson( + id = properties("group"), + + version = properties("version"), + meta = ExtensionJsonMeta( + name = "Coder Toolbox", + description = "Connects your JetBrains IDE to Coder workspaces", + vendor = "Coder", + url = "https://github.com/coder/coder-jetbrains-toolbox-plugin", + ) +) + +val extensionJsonFile = layout.buildDirectory.file("generated/extension.json") +val extensionJson by tasks.registering { + inputs.property("extension", extension.toString()) + + outputs.file(extensionJsonFile) + doLast { + generateExtensionJson(extension, extensionJsonFile.get().asFile.toPath()) + } +} changelog { - version.set(pluginVersion) + version.set(extension.version) groups.set(emptyList()) title.set("Coder Toolbox Plugin Changelog") } @@ -76,24 +109,30 @@ tasks.test { useJUnitPlatform() } -val assemblePlugin by tasks.registering(Jar::class) { - archiveBaseName.set(pluginId) - from(sourceSets.main.get().output) + +tasks.jar { + archiveBaseName.set(extension.id) + dependsOn(extensionJson) + from(extensionJson.get().outputs) } val copyPlugin by tasks.creating(Sync::class.java) { - dependsOn(assemblePlugin) - fromCompileDependencies() + dependsOn(tasks.jar) + dependsOn(tasks.getByName("generateLicenseReport")) + fromCompileDependencies() into(getPluginInstallDir()) } fun CopySpec.fromCompileDependencies() { - from(assemblePlugin.get().outputs.files) + from(tasks.jar) + from(extensionJson.get().outputs.files) from("src/main/resources") { - include("extension.json") include("dependencies.json") + } + from("src/main/resources") { include("icon.svg") + rename("icon.svg", "pluginIcon.svg") } // Copy dependencies, excluding those provided by Toolbox. @@ -106,6 +145,7 @@ fun CopySpec.fromCompileDependencies() { "core-api", "ui-api", "annotations", + "localization-api" ).any { file.name.contains(it) } } }, @@ -113,11 +153,12 @@ fun CopySpec.fromCompileDependencies() { } val pluginZip by tasks.creating(Zip::class) { - dependsOn(assemblePlugin) + archiveBaseName.set(properties("name")) + dependsOn(tasks.jar) + dependsOn(tasks.getByName("generateLicenseReport")) fromCompileDependencies() - into(pluginId) - archiveBaseName.set(pluginName) + into(extension.id) // folder like com.coder.toolbox } tasks.register("cleanAll", Delete::class.java) { @@ -142,7 +183,7 @@ private fun getPluginInstallDir(): Path { else -> error("Unknown os") } / "plugins" - return pluginsDir / pluginId + return pluginsDir / extension.id } val publishPlugin by tasks.creating { @@ -158,17 +199,49 @@ val publishPlugin by tasks.creating { // instance.uploader.uploadNewPlugin(pluginZip.outputs.files.singleFile, listOf("toolbox", "gateway"), LicenseUrl.APACHE_2_0, ProductFamily.TOOLBOX) // subsequent updates - instance.uploader.upload(pluginId, pluginZip.outputs.files.singleFile) + instance.uploader.upload(extension.id, pluginZip.outputs.files.singleFile) } } -// For use with kotlin-language-server. -tasks.register("classpath") { - doFirst { - File("classpath").writeText( - sourceSets["main"].runtimeClasspath.asPath +fun properties(key: String) = project.findProperty(key).toString() + +gettext { + potFile = project.layout.projectDirectory.file("src/main/resources/localization/defaultMessages.pot") + keywords = listOf("ptrc:1c,2", "ptrl") +} + +// region will be moved to the gradle plugin late +data class ExtensionJsonMeta( + val name: String, + val description: String, + val vendor: String, + val url: String?, +) + +data class ExtensionJson( + val id: String, + val version: String, + val meta: ExtensionJsonMeta, +) + +fun generateExtensionJson(extensionJson: ExtensionJson, destinationFile: Path) { + val descriptor = ToolboxPluginDescriptor( + id = extensionJson.id, + version = extensionJson.version, + apiVersion = libs.versions.toolbox.plugin.api.get(), + meta = ToolboxMeta( + name = extensionJson.meta.name, + description = extensionJson.meta.description, + vendor = extensionJson.meta.vendor, + url = extensionJson.meta.url, ) - } + ) + destinationFile.parent.createDirectories() + destinationFile.writeText( + jacksonMapperBuilder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build() + .writeValueAsString(descriptor) + ) } - -fun properties(key: String) = project.findProperty(key).toString() \ No newline at end of file +// endregion \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index faddad6..4a2e964 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,9 @@ [versions] -toolbox-plugin-api = "0.7.2.6.0.38311" +toolbox-plugin-api = "1.0.38881" kotlin = "2.1.0" coroutines = "1.10.1" serialization = "1.8.0" okhttp = "4.10.0" -slf4j = "2.0.17" dependency-license-report = "2.9" marketplace-client = "2.0.45" gradle-wrapper = "0.14.0" @@ -13,6 +12,9 @@ moshi = "1.15.2" ksp = "2.1.0-1.0.29" retrofit = "2.11.0" changelog = "2.2.1" +gettext = "0.7.0" +plugin-structure = "3.298" +mockk = "1.13.17" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -23,23 +25,24 @@ serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cor serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } exec = { module = "org.zeroturnaround:zt-exec", version.ref = "exec" } -moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi"} -moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi"} -retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit"} -retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit"} - +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } +plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" } +mokk = { module = "io.mockk:mockk", version.ref = "mockk" } marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } [bundles] -serialization = [ "serialization-core", "serialization-json", "serialization-json-okio" ] -toolbox-plugin-api = [ "toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api" ] +serialization = ["serialization-core", "serialization-json", "serialization-json-okio"] +toolbox-plugin-api = ["toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } dependency-license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "dependency-license-report" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } gradle-wrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradle-wrapper" } -changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } \ No newline at end of file +changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "gettext" } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index ad9d82f..5aa09aa 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -9,15 +9,16 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.EnvironmentView -import com.jetbrains.toolbox.api.core.ServiceLocator -import com.jetbrains.toolbox.api.remoteDev.AbstractRemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState +import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView -import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateConsumer -import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager -import com.jetbrains.toolbox.api.ui.ToolboxUi -import kotlinx.coroutines.CoroutineScope +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription +import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState +import com.jetbrains.toolbox.api.ui.actions.ActionDescription import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout @@ -30,68 +31,59 @@ import kotlin.time.Duration.Companion.seconds * Used in the environment list view. */ class CoderRemoteEnvironment( - private val serviceLocator: ServiceLocator, + private val context: CoderToolboxContext, private val client: CoderRestClient, private var workspace: Workspace, private var agent: WorkspaceAgent, - private var cs: CoroutineScope, -) : AbstractRemoteProviderEnvironment("${workspace.name}.${agent.name}") { - private var status = WorkspaceAndAgentStatus.from(workspace, agent) - - private val ui: ToolboxUi = serviceLocator.getService(ToolboxUi::class.java) +) : RemoteProviderEnvironment("${workspace.name}.${agent.name}") { + private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" + override val state: MutableStateFlow = + MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) + override val description: MutableStateFlow = + MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) - init { - actionsList.add( - Action("Open web terminal") { - cs.launch { + override val actionsList: StateFlow> = MutableStateFlow( + listOf( + Action(context.i18n.ptrl("Open web terminal")) { + context.cs.launch { BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { - ui.showErrorInfoPopup(it) + context.ui.showErrorInfoPopup(it) } } }, - ) - actionsList.add( - Action("Open in dashboard") { - cs.launch { + Action(context.i18n.ptrl("Open in dashboard")) { + context.cs.launch { BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) { - ui.showErrorInfoPopup(it) + context.ui.showErrorInfoPopup(it) } } }, - ) - actionsList.add( - Action("View template") { - cs.launch { + + Action(context.i18n.ptrl("View template")) { + context.cs.launch { BrowserUtil.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { - ui.showErrorInfoPopup(it) + context.ui.showErrorInfoPopup(it) } } }, - ) - actionsList.add( - Action("Start", enabled = { status.canStart() }) { + Action(context.i18n.ptrl("Start"), enabled = { wsRawStatus.canStart() }) { val build = client.startWorkspace(workspace) workspace = workspace.copy(latestBuild = build) update(workspace, agent) }, - ) - actionsList.add( - Action("Stop", enabled = { status.canStop() }) { + Action(context.i18n.ptrl("Stop"), enabled = { wsRawStatus.canStop() }) { val build = client.stopWorkspace(workspace) workspace = workspace.copy(latestBuild = build) update(workspace, agent) }, - ) - actionsList.add( - Action("Update", enabled = { workspace.outdated }) { + Action(context.i18n.ptrl("Update"), enabled = { workspace.outdated }) { val build = client.updateWorkspace(workspace) workspace = workspace.copy(latestBuild = build) update(workspace, agent) - }, - ) - } + }) + ) /** * Update the workspace/agent status to the listeners, if it has changed. @@ -99,11 +91,11 @@ class CoderRemoteEnvironment( fun update(workspace: Workspace, agent: WorkspaceAgent) { this.workspace = workspace this.agent = agent - val newStatus = WorkspaceAndAgentStatus.from(workspace, agent) - if (newStatus != status) { - status = newStatus - val state = status.toRemoteEnvironmentState(serviceLocator) - listenerSet.forEach { it.consume(state) } + wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + context.cs.launch { + state.update { + wsRawStatus.toRemoteEnvironmentState(context) + } } } @@ -111,7 +103,8 @@ class CoderRemoteEnvironment( * The contents are provided by the SSH view provided by Toolbox, all we * have to do is provide it a host name. */ - override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView(client.url, workspace, agent) + override suspend + fun getContentsView(): EnvironmentContentsView = EnvironmentView(client.url, workspace, agent) /** * Does nothing. In theory, we could do something like start the workspace @@ -124,46 +117,45 @@ class CoderRemoteEnvironment( /** * Immediately send the state to the listener and store for updates. */ - override fun addStateListener(consumer: EnvironmentStateConsumer): Boolean { - // TODO@JB: It would be ideal if we could have the workspace state and - // the connected state listed separately, since right now the - // connected state can mask the workspace state. - // TODO@JB: You can still press connect if the environment is - // unreachable. Is that expected? - consumer.consume(status.toRemoteEnvironmentState(serviceLocator)) - return super.addStateListener(consumer) - } +// override fun addStateListener(consumer: EnvironmentStateConsumer): Boolean { +// // TODO@JB: It would be ideal if we could have the workspace state and +// // the connected state listed separately, since right now the +// // connected state can mask the workspace state. +// // TODO@JB: You can still press connect if the environment is +// // unreachable. Is that expected? +// consumer.consume(status.toRemoteEnvironmentState(serviceLocator)) +// return super.addStateListener(consumer) +// } override fun onDelete() { - cs.launch { + context.cs.launch { // TODO info and cancel pop-ups only appear on the main page where all environments are listed. // However, #showSnackbar works on other pages. Until JetBrains fixes this issue we are going to use the snackbar - val shouldDelete = if (status.canStop()) { - ui.showOkCancelPopup( - "Delete running workspace?", - "Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical.", - "Delete", - "Cancel" + val shouldDelete = if (wsRawStatus.canStop()) { + context.ui.showOkCancelPopup( + context.i18n.ptrl("Delete running workspace?"), + context.i18n.ptrl("Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical."), + context.i18n.ptrl("Delete"), + context.i18n.ptrl("Cancel") ) } else { - ui.showOkCancelPopup( - "Delete workspace?", - "All the information in this workspace will be lost, including all files, unsaved changes and historical.", - "Delete", - "Cancel" + context.ui.showOkCancelPopup( + context.i18n.ptrl("Delete workspace?"), + context.i18n.ptrl("All the information in this workspace will be lost, including all files, unsaved changes and historical."), + context.i18n.ptrl("Delete"), + context.i18n.ptrl("Cancel") ) } if (shouldDelete) { try { client.removeWorkspace(workspace) - cs.launch { + context.cs.launch { withTimeout(5.minutes) { var workspaceStillExists = true - while (cs.isActive && workspaceStillExists) { - if (status == WorkspaceAndAgentStatus.DELETING || status == WorkspaceAndAgentStatus.DELETED) { + while (context.cs.isActive && workspaceStillExists) { + if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { workspaceStillExists = false - serviceLocator.getService(EnvironmentUiPageManager::class.java) - .showPluginEnvironmentsPage() + context.envPageManager.showPluginEnvironmentsPage() } else { delay(1.seconds) } @@ -171,7 +163,7 @@ class CoderRemoteEnvironment( } } } catch (e: APIResponseException) { - ui.showErrorInfoPopup(e) + context.ui.showErrorInfoPopup(e) } } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index a360229..a449c39 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -1,7 +1,6 @@ package com.coder.toolbox import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.logger.CoderLoggerFactory import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.services.CoderSecretsService @@ -17,21 +16,18 @@ import com.coder.toolbox.views.ConnectPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.SignInPage import com.coder.toolbox.views.TokenPage -import com.jetbrains.toolbox.api.core.PluginSecretStore -import com.jetbrains.toolbox.api.core.PluginSettingsStore -import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon +import com.jetbrains.toolbox.api.core.util.LoadableState import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState -import com.jetbrains.toolbox.api.remoteDev.RemoteEnvironmentConsumer import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager -import com.jetbrains.toolbox.api.ui.ToolboxUi -import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription -import com.jetbrains.toolbox.api.ui.components.AccountDropdownField +import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment +import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -39,30 +35,24 @@ import java.net.URI import java.net.URL import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds +import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownMenu +import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory class CoderRemoteProvider( - private val serviceLocator: ServiceLocator, + private val context: CoderToolboxContext, private val httpClient: OkHttpClient, ) : RemoteProvider("Coder") { - private val logger = CoderLoggerFactory.getLogger(javaClass) - - private val ui: ToolboxUi = serviceLocator.getService(ToolboxUi::class.java) - private val consumer: RemoteEnvironmentConsumer = serviceLocator.getService(RemoteEnvironmentConsumer::class.java) - private val coroutineScope: CoroutineScope = serviceLocator.getService(CoroutineScope::class.java) - private val settingsStore: PluginSettingsStore = serviceLocator.getService(PluginSettingsStore::class.java) - private val secretsStore: PluginSecretStore = serviceLocator.getService(PluginSecretStore::class.java) - // Current polling job. private var pollJob: Job? = null private var lastEnvironments: Set? = null // Create our services from the Toolbox ones. - private val settingsService = CoderSettingsService(settingsStore) - private val settings: CoderSettings = CoderSettings(settingsService) - private val secrets: CoderSecretsService = CoderSecretsService(secretsStore) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(settingsService) - private val dialogUi = DialogUi(settings, ui) - private val linkHandler = LinkHandler(settings, httpClient, dialogUi) + private val settingsService = CoderSettingsService(context.settingsStore) + private val settings: CoderSettings = CoderSettings(settingsService, context.logger) + private val secrets: CoderSecretsService = CoderSecretsService(context.secretsStore) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, settingsService) + private val dialogUi = DialogUi(context, settings) + private val linkHandler = LinkHandler(context, settings, httpClient, dialogUi) // The REST client, if we are signed in private var client: CoderRestClient? = null @@ -75,16 +65,20 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true + override val environments: MutableStateFlow>> = MutableStateFlow( + LoadableState.Value(emptyList()) + ) + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the * first time). */ - private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = coroutineScope.launch { + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch { while (isActive) { try { - logger.debug("Fetching workspace agents from {}", client.url) - val environments = client.workspaces().flatMap { ws -> + context.logger.debug("Fetching workspace agents from ${client.url}") + val resolvedEnvironments = client.workspaces().flatMap { ws -> // Agents are not included in workspaces that are off // so fetch them separately. when (ws.latestBuild.status) { @@ -100,7 +94,7 @@ class CoderRemoteProvider( it.name }?.map { agent -> // If we have an environment already, update that. - val env = CoderRemoteEnvironment(serviceLocator, client, ws, agent, coroutineScope) + val env = CoderRemoteEnvironment(context, client, ws, agent) lastEnvironments?.firstOrNull { it == env }?.let { it.update(ws, agent) it @@ -117,21 +111,23 @@ class CoderRemoteProvider( // Reconfigure if a new environment is found. // TODO@JB: Should we use the add/remove listeners instead? val newEnvironments = lastEnvironments - ?.let { environments.subtract(it) } - ?: environments + ?.let { resolvedEnvironments.subtract(it) } + ?: resolvedEnvironments if (newEnvironments.isNotEmpty()) { - logger.info("Found new environment(s), reconfiguring CLI: {}", newEnvironments) + context.logger.info("Found new environment(s), reconfiguring CLI: $newEnvironments") cli.configSsh(newEnvironments.map { it.name }.toSet()) } - consumer.consumeEnvironments(environments, true) + environments.update { + LoadableState.Value(resolvedEnvironments.toList()) + } - lastEnvironments = environments + lastEnvironments = resolvedEnvironments } catch (_: CancellationException) { - logger.debug("{} polling loop canceled", client.url) + context.logger.debug("${client.url} polling loop canceled") break } catch (ex: Exception) { - logger.info("setting exception $ex") + context.logger.info(ex, "workspace polling error encountered") pollError = ex logout() break @@ -155,21 +151,20 @@ class CoderRemoteProvider( /** * A dropdown that appears at the top of the environment list to the right. */ - override fun getAccountDropDown(): AccountDropdownField? { + override fun getAccountDropDown(): DropDownMenu? { val username = client?.me?.username if (username != null) { - return AccountDropdownField(username, Runnable { logout() }) + return dropDownFactory(context.i18n.pnotr(username), { logout() }) } return null } - /** - * List of actions that appear next to the account. - */ - override fun getAdditionalPluginActions(): List = listOf( - Action("Settings", closesPage = false) { - ui.showUiPage(settingsPage) - }, + override val additionalPluginActions: StateFlow> = MutableStateFlow( + listOf( + Action(context.i18n.ptrl("Settings")) { + context.ui.showUiPage(settingsPage) + }, + ) ) /** @@ -182,7 +177,7 @@ class CoderRemoteProvider( pollJob?.cancel() client = null lastEnvironments = null - consumer.consumeEnvironments(emptyList(), true) + environments.value = LoadableState.Value(emptyList()) } override val svgIcon: SvgIcon = @@ -211,7 +206,8 @@ class CoderRemoteProvider( * Just displays the deployment URL at the moment, but we could use this as * a form for creating new environments. */ - override fun getNewEnvironmentUiPage(): UiPage = NewEnvironmentPage(getDeploymentURL()?.first) + override fun getNewEnvironmentUiPage(): UiPage = + NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) /** * We always show a list of environments. @@ -226,25 +222,15 @@ class CoderRemoteProvider( */ override fun setVisible(visibilityState: ProviderVisibilityState) {} - /** - * Ignored; unsure if we should use this over the consumer we get passed in. - */ - override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} - - /** - * Ignored; unsure if we should use this over the consumer we get passed in. - */ - override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} - /** * Handle incoming links (like from the dashboard). */ - override fun handleUri(uri: URI) { + override suspend fun handleUri(uri: URI) { val params = uri.toQueryParameters() - coroutineScope.launch { + context.cs.launch { val name = linkHandler.handle(params) // TODO@JB: Now what? How do we actually connect this workspace? - logger.debug("External request for {}: {}", name, uri) + context.logger.debug("External request for $name: $uri") } } @@ -257,7 +243,7 @@ class CoderRemoteProvider( * than using multiple root pages. */ private fun goToEnvironmentsPage() { - serviceLocator.getService(EnvironmentUiPageManager::class.java).showPluginEnvironmentsPage() + context.envPageManager.showPluginEnvironmentsPage() } /** @@ -286,13 +272,18 @@ class CoderRemoteProvider( firstRun = false // Login flow. - val signInPage = SignInPage(getDeploymentURL()) { deploymentURL -> - ui.showUiPage( - TokenPage(deploymentURL, getToken(deploymentURL)) { selectedToken -> - ui.showUiPage(createConnectPage(deploymentURL, selectedToken)) - }, - ) - } + val signInPage = + SignInPage(context, getDeploymentURL()) { deploymentURL -> + context.ui.showUiPage( + TokenPage( + context, + deploymentURL, + getToken(deploymentURL) + ) { selectedToken -> + context.ui.showUiPage(createConnectPage(deploymentURL, selectedToken)) + }, + ) + } // We might have tried and failed to automatically log in. autologinEx?.let { signInPage.notify("Error logging in", it) } @@ -308,11 +299,11 @@ class CoderRemoteProvider( * Create a connect page that starts polling and resets the UI on success. */ private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage( + context, deploymentURL, token, settings, httpClient, - coroutineScope, ::goToEnvironmentsPage, ) { client, cli -> // Store the URL and token for use next time. diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt new file mode 100644 index 0000000..76738d5 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -0,0 +1,21 @@ +package com.coder.toolbox + +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope + +data class CoderToolboxContext( + val ui: ToolboxUi, + val envPageManager: EnvironmentUiPageManager, + val envStateColorPalette: EnvironmentStateColorPalette, + val cs: CoroutineScope, + val logger: Logger, + val i18n: LocalizableStringFactory, + val settingsStore: PluginSettingsStore, + val secretsStore: PluginSecretStore +) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 7875cf7..8ee06d1 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,10 +1,16 @@ package com.coder.toolbox -import com.coder.toolbox.logger.CoderLoggerFactory +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope import okhttp3.OkHttpClient /** @@ -13,11 +19,17 @@ import okhttp3.OkHttpClient class CoderToolboxExtension : RemoteDevExtension { // All services must be passed in here and threaded as necessary. override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { - // initialize logger factory - CoderLoggerFactory.tLogger = serviceLocator.getService(Logger::class.java) - return CoderRemoteProvider( - serviceLocator, + CoderToolboxContext( + serviceLocator.getService(ToolboxUi::class.java), + serviceLocator.getService(EnvironmentUiPageManager::class.java), + serviceLocator.getService(EnvironmentStateColorPalette::class.java), + serviceLocator.getService(CoroutineScope::class.java), + serviceLocator.getService(Logger::class.java), + serviceLocator.getService(LocalizableStringFactory::class.java), + serviceLocator.getService(PluginSettingsStore::class.java), + serviceLocator.getService(PluginSecretStore::class.java), + ), OkHttpClient(), ) } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 707cb5b..d4f347f 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -1,9 +1,9 @@ package com.coder.toolbox.cli +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ex.MissingVersionException import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException -import com.coder.toolbox.logger.CoderLoggerFactory import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.CoderSettingsState import com.coder.toolbox.util.CoderHostnameVerifier @@ -17,6 +17,7 @@ import com.coder.toolbox.util.getHeaders import com.coder.toolbox.util.getOS import com.coder.toolbox.util.safeHost import com.coder.toolbox.util.sha1 +import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException @@ -55,12 +56,13 @@ internal data class Version( * from step 2 with the data directory. */ fun ensureCLI( + context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, settings: CoderSettings, indicator: ((t: String) -> Unit)? = null, ): CoderCLIManager { - val cli = CoderCLIManager(deploymentURL, settings) + val cli = CoderCLIManager(deploymentURL, context.logger, settings) // Short-circuit if we already have the expected version. This // lets us bypass the 304 which is slower and may not be @@ -89,7 +91,7 @@ fun ensureCLI( } // Try falling back to the data directory. - val dataCLI = CoderCLIManager(deploymentURL, settings, true) + val dataCLI = CoderCLIManager(deploymentURL, context.logger, settings, true) val dataCLIMatches = dataCLI.matchesVersion(buildVersion) if (dataCLIMatches == true) { return dataCLI @@ -120,14 +122,13 @@ data class Features( class CoderCLIManager( // The URL of the deployment this CLI is for. private val deploymentURL: URL, + private val logger: Logger, // Plugin configuration. - private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + private val settings: CoderSettings = CoderSettings(CoderSettingsState(), logger), // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. forceDownloadToData: Boolean = false, ) { - private val logger = CoderLoggerFactory.getLogger(javaClass) - val remoteBinaryURL: URL = settings.binSource(deploymentURL) val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") @@ -196,7 +197,7 @@ class CoderCLIManager( } catch (e: FileNotFoundException) { null } catch (e: Exception) { - logger.warn("Unable to calculate hash for $localBinaryPath", e) + logger.warn(e, "Unable to calculate hash for $localBinaryPath") null } @@ -275,7 +276,8 @@ class CoderCLIManager( if (settings.sshLogDirectory.isNotBlank()) escape(settings.sshLogDirectory) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val backgroundProxyArgs = baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) + val backgroundProxyArgs = + baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) val extraConfig = if (settings.sshConfigOptions.isNotBlank()) { "\n" + settings.sshConfigOptions.prependIndent(" ") @@ -417,6 +419,7 @@ class CoderCLIManager( is InvalidVersionException -> { logger.info("Got invalid version from $localBinaryPath: ${e.message}") } + else -> { // An error here most likely means the CLI does not exist or // it executed successfully but output no version which diff --git a/src/main/kotlin/com/coder/toolbox/logger/CoderLoggerFactory.kt b/src/main/kotlin/com/coder/toolbox/logger/CoderLoggerFactory.kt deleted file mode 100644 index 58b7fb4..0000000 --- a/src/main/kotlin/com/coder/toolbox/logger/CoderLoggerFactory.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.coder.toolbox.logger - -import org.slf4j.ILoggerFactory -import org.slf4j.Logger -import com.jetbrains.toolbox.api.core.diagnostics.Logger as ToolboxLogger - -object CoderLoggerFactory : ILoggerFactory { - var tLogger: ToolboxLogger? = null - - fun getLogger(clazz: Class): Logger = getLogger(clazz.name) - override fun getLogger(clazzName: String): Logger = LoggerImpl(clazzName, tLogger) -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/logger/LoggerImpl.kt b/src/main/kotlin/com/coder/toolbox/logger/LoggerImpl.kt deleted file mode 100644 index a476666..0000000 --- a/src/main/kotlin/com/coder/toolbox/logger/LoggerImpl.kt +++ /dev/null @@ -1,235 +0,0 @@ -package com.coder.toolbox.logger - -import org.slf4j.Logger -import org.slf4j.Marker -import com.jetbrains.toolbox.api.core.diagnostics.Logger as ToolboxLogger - -class LoggerImpl(private val clazzName: String, private val tLogger: ToolboxLogger?) : Logger { - override fun getName(): String = clazzName - - override fun isTraceEnabled(): Boolean = true - - override fun trace(message: String) { - tLogger?.trace(message) - } - - override fun trace(message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(message: String, exception: Throwable) { - tLogger?.trace(exception, message) - } - - override fun isTraceEnabled(marker: Marker): Boolean = true - - override fun trace(marker: Marker, message: String) { - tLogger?.trace(message) - } - - override fun trace(marker: Marker, message: String, arg: Any) { - extractThrowable(arg)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(marker: Marker, message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(marker: Marker, message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.trace(it, message) } ?: tLogger?.trace(message) - } - - override fun trace(marker: Marker, message: String, exception: Throwable) { - tLogger?.trace(exception, message) - } - - override fun isDebugEnabled(): Boolean = true - - override fun debug(message: String) { - tLogger?.debug(message) - } - - override fun debug(message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(message: String, exception: Throwable) { - tLogger?.debug(exception, message) - } - - override fun isDebugEnabled(marker: Marker): Boolean = true - - override fun debug(marker: Marker, message: String) { - tLogger?.debug(message) - } - - override fun debug(marker: Marker, message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(marker: Marker, message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(marker: Marker, message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.debug(it, message) } ?: tLogger?.debug(message) - } - - override fun debug(marker: Marker, message: String, exception: Throwable) { - tLogger?.debug(exception, message) - } - - override fun isInfoEnabled(): Boolean = true - - override fun info(message: String) { - tLogger?.info(message) - } - - override fun info(message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(message: String, exception: Throwable) { - tLogger?.info(exception, message) - } - - override fun isInfoEnabled(marker: Marker): Boolean = true - - override fun info(marker: Marker, message: String) { - tLogger?.info(message) - } - - override fun info(marker: Marker, message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(marker: Marker, message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(marker: Marker, message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.info(it, message) } ?: tLogger?.info(message) - } - - override fun info(marker: Marker, message: String, exception: Throwable) { - tLogger?.info(exception, message) - } - - override fun isWarnEnabled(): Boolean = true - - override fun warn(message: String) { - tLogger?.warn(message) - } - - override fun warn(message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(message: String, exception: Throwable) { - tLogger?.warn(exception, message) - } - - override fun isWarnEnabled(marker: Marker): Boolean = true - - override fun warn(marker: Marker, message: String) { - tLogger?.warn(message) - } - - override fun warn(marker: Marker, message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(marker: Marker, message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(marker: Marker, message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.warn(it, message) } ?: tLogger?.warn(message) - } - - override fun warn(marker: Marker, message: String, exception: Throwable) { - tLogger?.warn(exception, message) - } - - override fun isErrorEnabled(): Boolean = true - - override fun error(message: String) { - tLogger?.error(message) - } - - override fun error(message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(message: String, exception: Throwable) { - tLogger?.error(exception, message) - } - - override fun isErrorEnabled(marker: Marker): Boolean = true - - override fun error(marker: Marker, message: String) { - tLogger?.error(message) - } - - override fun error(marker: Marker, message: String, arg: Any?) { - extractThrowable(arg)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(marker: Marker, message: String, firstArg: Any?, secondArg: Any?) { - extractThrowable(firstArg, secondArg)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(marker: Marker, message: String, vararg args: Any?) { - extractThrowable(args)?.let { tLogger?.error(it, message) } ?: tLogger?.error(message) - } - - override fun error(marker: Marker, message: String, exception: Throwable) { - tLogger?.error(exception, message) - } - - companion object { - fun extractThrowable(vararg args: Any?): Throwable? = args.firstOrNull { it is Throwable } as? Throwable - - fun extractThrowable(arg: Any?): Throwable? = arg as? Throwable - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index dd5bb8b..3599782 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -1,14 +1,13 @@ package com.coder.toolbox.models +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.ui.color.StateColor import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState -import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState @@ -59,25 +58,23 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(serviceLocator: ServiceLocator): CustomRemoteEnvironmentState { + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { return CustomRemoteEnvironmentState( label, - getStateColor(serviceLocator), + getStateColor(context), ready(), // reachable // TODO@JB: How does this work? Would like a spinner for pending states. getStateIcon() ) } - private fun getStateColor(serviceLocator: ServiceLocator): StateColor { - val colorPalette = serviceLocator.getService(EnvironmentStateColorPalette::class.java) - - return if (ready()) colorPalette.getColor(StandardRemoteEnvironmentState.Active) - else if (canStart()) colorPalette.getColor(StandardRemoteEnvironmentState.Failed) - else if (pending()) colorPalette.getColor(StandardRemoteEnvironmentState.Activating) - else if (this == DELETING) colorPalette.getColor(StandardRemoteEnvironmentState.Deleting) - else if (this == DELETED) colorPalette.getColor(StandardRemoteEnvironmentState.Deleted) - else colorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) + private fun getStateColor(context: CoderToolboxContext): StateColor { + return if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) + else if (canStart()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Failed) + else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) + else if (this == DELETING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) + else if (this == DELETED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) + else context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) } private fun getStateIcon(): EnvironmentStateIcons { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 371c818..2d2c49e 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.OSConverter @@ -49,9 +50,10 @@ data class ProxyValues( * The token can be omitted if some other authentication mechanism is in use. */ open class CoderRestClient( + context: CoderToolboxContext, val url: URL, val token: String?, - private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + private val settings: CoderSettings = CoderSettings(CoderSettingsState(), context.logger), private val proxyValues: ProxyValues? = null, private val pluginVersion: String = "development", existingHttpClient: OkHttpClient? = null, diff --git a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt index ddcd269..0f95798 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.settings -import com.coder.toolbox.logger.CoderLoggerFactory import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -9,6 +8,7 @@ import com.coder.toolbox.util.getOS import com.coder.toolbox.util.safeHost import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath +import com.jetbrains.toolbox.api.core.diagnostics.Logger import java.net.URL import java.nio.file.Files import java.nio.file.Path @@ -34,7 +34,7 @@ enum class Source { * Return a description of the source. */ fun description(name: String): String = when (this) { - CONFIG -> "This $name was pulled from your global CLI config." + CONFIG -> "This $name was pulled from your global CLI config." DEPLOYMENT_CONFIG -> "This $name was pulled from your deployment's CLI config." LAST_USED -> "This was the last used $name." QUERY -> "This $name was pulled from the Gateway link." @@ -120,6 +120,7 @@ data class CoderTLSSettings(private val state: CoderSettingsState) { open class CoderSettings( // Raw mutable setting state. private val state: CoderSettingsState, + private val logger: Logger, // The location of the SSH config. Defaults to ~/.ssh/config. val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"), // Overrides the default environment (for tests). @@ -127,8 +128,6 @@ open class CoderSettings( // Overrides the default binary name (for tests). private val binaryName: String? = null, ) { - private val logger = CoderLoggerFactory.getLogger(javaClass) - val tls = CoderTLSSettings(state) /** @@ -284,12 +283,12 @@ open class CoderSettings( // SSH has not been configured yet, or using some other authorization mechanism. null } to - try { - Files.readString(dir.resolve("session")) - } catch (e: Exception) { - // SSH has not been configured yet, or using some other authorization mechanism. - null - } + try { + Files.readString(dir.resolve("session")) + } catch (e: Exception) { + // SSH has not been configured yet, or using some other authorization mechanism. + null + } } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index 8414e9d..0b08a3b 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,9 +1,10 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.browser.BrowserUtil import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.Source -import com.jetbrains.toolbox.api.ui.ToolboxUi +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType import java.net.URL @@ -13,28 +14,35 @@ import java.net.URL * This is meant to mimic ToolboxUi. */ class DialogUi( + private val context: CoderToolboxContext, private val settings: CoderSettings, - private val ui: ToolboxUi, ) { - suspend fun confirm(title: String, description: String): Boolean { - return ui.showOkCancelPopup(title, description, "Yes", "No") + + suspend fun confirm(title: LocalizableString, description: LocalizableString): Boolean { + return context.ui.showOkCancelPopup(title, description, context.i18n.ptrl("Yes"), context.i18n.ptrl("No")) } suspend fun ask( - title: String, - description: String, - placeholder: String? = null, - // There is no link or error support in Toolbox so for now isError and - // link are unused. + title: LocalizableString, + description: LocalizableString, + placeholder: LocalizableString? = null, + // TODO check: there is no link or error support in Toolbox so for now isError and link are unused. isError: Boolean = false, link: Pair? = null, ): String? { - return ui.showTextInputPopup(title, description, placeholder, TextType.General, "OK", "Cancel") + return context.ui.showTextInputPopup( + title, + description, + placeholder, + TextType.General, + context.i18n.ptrl("OK"), + context.i18n.ptrl("Cancel") + ) } private suspend fun openUrl(url: URL) { BrowserUtil.browse(url.toString()) { - ui.showErrorInfoPopup(it) + context.ui.showErrorInfoPopup(it) } } @@ -79,11 +87,13 @@ class DialogUi( // for the token. val tokenFromUser = ask( - title = "Session Token", - description = error - ?: token?.second?.description("token") - ?: "No existing token for ${url.host} found.", - placeholder = token?.first, + title = context.i18n.ptrl("Session Token"), + description = context.i18n.pnotr( + error + ?: token?.second?.description("token") + ?: "No existing token for ${url.host} found." + ), + placeholder = token?.first?.let { context.i18n.pnotr(it) }, link = Pair("Session Token:", getTokenUrl.toString()), isError = error != null, ) diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt b/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt index 9c6342e..31a6602 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.plugin.PluginManager @@ -15,6 +16,7 @@ import java.net.HttpURLConnection import java.net.URL open class LinkHandler( + private val context: CoderToolboxContext, private val settings: CoderSettings, private val httpClient: OkHttpClient?, private val dialogUi: DialogUi, @@ -31,7 +33,10 @@ open class LinkHandler( indicator: ((t: String) -> Unit)? = null, ): String { val deploymentURL = - parameters.url() ?: dialogUi.ask("Deployment URL", "Enter the full URL of your Coder deployment") + parameters.url() ?: dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) if (deploymentURL.isNullOrBlank()) { throw MissingArgumentException("Query parameter \"$URL\" is missing") } @@ -108,6 +113,7 @@ open class LinkHandler( val cli = ensureCLI( + context, deploymentURL.toURL(), client.buildInfo().version, settings, @@ -165,6 +171,7 @@ open class LinkHandler( // The http client Toolbox gives us is already set up with the // proxy config, so we do net need to explicitly add it. val client = CoderRestClient( + context, deploymentURL.toURL(), token?.first, settings, @@ -222,8 +229,8 @@ open class LinkHandler( } if (!dialogUi.confirm( - "Confirm download URL", - "$comment. Would you like to proceed to $linkWithRedirect?", + context.i18n.ptrl("Confirm download URL"), + context.i18n.pnotr("$comment. Would you like to proceed to $linkWithRedirect?"), ) ) { throw IllegalArgumentException("$linkWithRedirect is not allowlisted") diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 0d17560..c69aaff 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.util -import com.coder.toolbox.logger.CoderLoggerFactory import com.coder.toolbox.settings.CoderTLSSettings import okhttp3.internal.tls.OkHostnameVerifier import java.io.File @@ -112,7 +111,8 @@ fun coderTrustManagers(tlsCAPath: String): Array { return trustManagerFactory.trustManagers.map { MergedSystemTrustManger(it as X509TrustManager) }.toTypedArray() } -class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : SSLSocketFactory() { +class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : + SSLSocketFactory() { override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites @@ -182,8 +182,6 @@ class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, priv } class CoderHostnameVerifier(private val alternateName: String) : HostnameVerifier { - private val logger = CoderLoggerFactory.getLogger(javaClass) - override fun verify( host: String, session: SSLSession, @@ -203,7 +201,6 @@ class CoderHostnameVerifier(private val alternateName: String) : HostnameVerifie continue } val hostname = entry[1] as String - logger.debug("Found cert hostname: $hostname") if (hostname.lowercase(Locale.getDefault()) == alternateName) { return true } @@ -244,5 +241,6 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : } } - override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers + override fun getAcceptedIssuers(): Array = + otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index f2ce937..6a1c4e3 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,7 +1,8 @@ package com.coder.toolbox.views -import com.coder.toolbox.logger.CoderLoggerFactory +import com.coder.toolbox.CoderToolboxContext import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField import com.jetbrains.toolbox.api.ui.components.UiPage @@ -19,11 +20,10 @@ import java.util.function.Consumer * to use the mouse. */ abstract class CoderPage( - title: String, + private val context: CoderToolboxContext, + title: LocalizableString, showIcon: Boolean = true, ) : UiPage(title) { - private val logger = CoderLoggerFactory.getLogger(javaClass) - /** * An error to display on the page. * @@ -32,7 +32,7 @@ abstract class CoderPage( protected var errorField: ValidationErrorField? = null /** Toolbox uses this to show notifications on the page. */ - private var notifier: Consumer? = null + private var notifier: ((Throwable) -> Unit)? = null /** Let Toolbox know the fields should be updated. */ protected var listener: Consumer? = null @@ -55,19 +55,19 @@ abstract class CoderPage( * Show an error as a popup on this page. */ fun notify(logPrefix: String, ex: Throwable) { - logger.error(logPrefix, ex) + context.logger.error(ex, logPrefix) // It is possible the error listener is not attached yet. - notifier?.accept(ex) ?: errorBuffer.add(ex) + notifier?.let { it(ex) } ?: errorBuffer.add(ex) } /** * Immediately notify any pending errors and store for later errors. */ - override fun setActionErrorNotifier(notifier: Consumer?) { + override fun setActionErrorNotifier(notifier: ((Throwable) -> Unit)?) { this.notifier = notifier notifier?.let { errorBuffer.forEach { - notifier.accept(it) + notifier(it) } errorBuffer.clear() } @@ -77,7 +77,7 @@ abstract class CoderPage( * Set/unset the field error and update the form. */ protected fun updateError(error: String?) { - errorField = error?.let { ValidationErrorField(error) } + errorField = error?.let { ValidationErrorField(context.i18n.pnotr(error)) } listener?.accept(null) // Make Toolbox get the fields again. } } @@ -86,12 +86,12 @@ abstract class CoderPage( * An action that simply runs the provided callback. */ class Action( - description: String, + description: LocalizableString, closesPage: Boolean = false, enabled: () -> Boolean = { true }, private val actionBlock: () -> Unit, ) : RunnableActionDescription { - override val label: String = description + override val label: LocalizableString = description override val shouldClosePage: Boolean = closesPage override val isEnabled: Boolean = enabled() override fun run() { diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index a4d7f19..be8dafa 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,11 +1,14 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.services.CoderSettingsService import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A page for modifying Coder settings. @@ -14,49 +17,60 @@ import com.jetbrains.toolbox.api.ui.components.UiField * 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 settings: CoderSettingsService) : CoderPage("Coder Settings", false) { +class CoderSettingsPage( + context: CoderToolboxContext, + private val settings: CoderSettingsService, +) : CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { // TODO: Copy over the descriptions, holding until I can test this page. - private val binarySourceField = TextField("Binary source", settings.binarySource, TextType.General) - private val binaryDirectoryField = TextField("Binary directory", settings.binaryDirectory, TextType.General) - private val dataDirectoryField = TextField("Data directory", settings.dataDirectory, TextType.General) - private val enableDownloadsField = CheckboxField(settings.enableDownloads, "Enable downloads") + private val binarySourceField = + TextField(context.i18n.ptrl("Binary source"), settings.binarySource, TextType.General) + private val binaryDirectoryField = + TextField(context.i18n.ptrl("Binary directory"), settings.binaryDirectory, TextType.General) + private val dataDirectoryField = + TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory, TextType.General) + private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) private val enableBinaryDirectoryFallbackField = - CheckboxField(settings.enableBinaryDirectoryFallback, "Enable binary directory fallback") - private val headerCommandField = TextField("Header command", settings.headerCommand, TextType.General) - private val tlsCertPathField = TextField("TLS cert path", settings.tlsCertPath, TextType.General) - private val tlsKeyPathField = TextField("TLS key path", settings.tlsKeyPath, TextType.General) - private val tlsCAPathField = TextField("TLS CA path", settings.tlsCAPath, TextType.General) + CheckboxField(settings.enableBinaryDirectoryFallback, context.i18n.ptrl("Enable binary directory fallback")) + private val headerCommandField = + TextField(context.i18n.ptrl("Header command"), settings.headerCommand, TextType.General) + private val tlsCertPathField = TextField(context.i18n.ptrl("TLS cert path"), settings.tlsCertPath, TextType.General) + private val tlsKeyPathField = TextField(context.i18n.ptrl("TLS key path"), settings.tlsKeyPath, TextType.General) + private val tlsCAPathField = TextField(context.i18n.ptrl("TLS CA path"), settings.tlsCAPath, TextType.General) private val tlsAlternateHostnameField = - TextField("TLS alternate hostname", settings.tlsAlternateHostname, TextType.General) - private val disableAutostartField = CheckboxField(settings.disableAutostart, "Disable autostart") + TextField(context.i18n.ptrl("TLS alternate hostname"), settings.tlsAlternateHostname, TextType.General) + private val disableAutostartField = CheckboxField(settings.disableAutostart, context.i18n.ptrl("Disable autostart")) - override val fields: MutableList = mutableListOf( - binarySourceField, - enableDownloadsField, - binaryDirectoryField, - enableBinaryDirectoryFallbackField, - dataDirectoryField, - headerCommandField, - tlsCertPathField, - tlsKeyPathField, - tlsCAPathField, - tlsAlternateHostnameField, - disableAutostartField, + override val fields: StateFlow> = MutableStateFlow( + listOf( + binarySourceField, + enableDownloadsField, + binaryDirectoryField, + enableBinaryDirectoryFallbackField, + dataDirectoryField, + headerCommandField, + tlsCertPathField, + tlsKeyPathField, + tlsCAPathField, + tlsAlternateHostnameField, + disableAutostartField + ) ) - override val actionButtons: MutableList = mutableListOf( - Action("Save", closesPage = true) { - settings.binarySource = binarySourceField.text.value - settings.binaryDirectory = binaryDirectoryField.text.value - settings.dataDirectory = dataDirectoryField.text.value - settings.enableDownloads = enableDownloadsField.checked.value - settings.enableBinaryDirectoryFallback = enableBinaryDirectoryFallbackField.checked.value - settings.headerCommand = headerCommandField.text.value - settings.tlsCertPath = tlsCertPathField.text.value - settings.tlsKeyPath = tlsKeyPathField.text.value - settings.tlsCAPath = tlsCAPathField.text.value - settings.tlsAlternateHostname = tlsAlternateHostnameField.text.value - settings.disableAutostart = disableAutostartField.checked.value - }, + override val actionButtons: StateFlow> = MutableStateFlow( + listOf( + Action(context.i18n.ptrl("Save"), closesPage = true) { + settings.binarySource = binarySourceField.textState.value + settings.binaryDirectory = binaryDirectoryField.textState.value + settings.dataDirectory = dataDirectoryField.textState.value + settings.enableDownloads = enableDownloadsField.checkedState.value + settings.enableBinaryDirectoryFallback = enableBinaryDirectoryFallbackField.checkedState.value + settings.headerCommand = headerCommandField.textState.value + settings.tlsCertPath = tlsCertPathField.textState.value + settings.tlsKeyPath = tlsKeyPathField.textState.value + settings.tlsCAPath = tlsCAPathField.textState.value + settings.tlsAlternateHostname = tlsAlternateHostnameField.textState.value + settings.disableAutostart = disableAutostartField.checkedState.value + }, + ) ) } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt index 5270578..9538d45 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt @@ -1,16 +1,19 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.util.humanizeConnectionError +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.UiField -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import java.net.URL @@ -19,22 +22,23 @@ import java.net.URL * A page that connects a REST client and cli to Coder. */ class ConnectPage( + private val context: CoderToolboxContext, private val url: URL, private val token: String?, private val settings: CoderSettings, private val httpClient: OkHttpClient, - private val coroutineScope: CoroutineScope, private val onCancel: () -> Unit, private val onConnect: ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage("Connecting to Coder") { +) : CoderPage(context, context.i18n.ptrl("Connecting to Coder")) { private var signInJob: Job? = null - private var statusField = LabelField("Connecting to ${url.host}...") + private var statusField = LabelField(context.i18n.pnotr("Connecting to ${url.host}...")) - override val description: String = "Please wait while we configure Toolbox for ${url.host}." + override val description: LocalizableString = + context.i18n.pnotr("Please wait while we configure Toolbox for ${url.host}.") init { connect() @@ -45,23 +49,26 @@ class ConnectPage( * * TODO@JB: This looks kinda sparse. A centered spinner would be welcome. */ - override val fields: MutableList = listOfNotNull( - statusField, - errorField, - ).toMutableList() + override val fields: StateFlow> = MutableStateFlow( + listOfNotNull( + statusField, + errorField + ) + ) /** * Show a retry button on error. */ - override val actionButtons: MutableList = listOfNotNull( - if (errorField != null) Action("Retry", closesPage = false) { retry() } else null, - if (errorField != null) Action("Cancel", closesPage = false) { onCancel() } else null, - ).toMutableList() + override val actionButtons: StateFlow> = MutableStateFlow( + listOfNotNull( + if (errorField != null) Action(context.i18n.ptrl("Retry"), closesPage = false) { retry() } else null, + if (errorField != null) Action(context.i18n.ptrl("Cancel"), closesPage = false) { onCancel() } else null, + )) /** * Update the status and error fields then refresh. */ - private fun updateStatus(newStatus: String, error: String?) { + private fun updateStatus(newStatus: LocalizableString, error: String?) { statusField = LabelField(newStatus) updateError(error) // Will refresh. } @@ -70,7 +77,7 @@ class ConnectPage( * Try connecting again after an error. */ private fun retry() { - updateStatus("Connecting to ${url.host}...", null) + updateStatus(context.i18n.pnotr("Connecting to ${url.host}..."), null) connect() } @@ -79,11 +86,12 @@ class ConnectPage( */ private fun connect() { signInJob?.cancel() - signInJob = coroutineScope.launch { + signInJob = context.cs.launch { try { // The http client Toolbox gives us is already set up with the // proxy config, so we do net need to explicitly add it. val client = CoderRestClient( + context, url, token, settings, @@ -92,13 +100,13 @@ class ConnectPage( httpClient ) client.authenticate() - updateStatus("Checking Coder binary...", error = null) - val cli = ensureCLI(client.url, client.buildVersion, settings) { status -> - updateStatus(status, error = null) + updateStatus(context.i18n.ptrl("Checking Coder binary..."), error = null) + val cli = ensureCLI(context, client.url, client.buildVersion, settings) { status -> + updateStatus(context.i18n.pnotr(status), error = null) } // We only need to log in if we are using token-based auth. if (client.token != null) { - updateStatus("Configuring CLI...", error = null) + updateStatus(context.i18n.ptrl("Configuring CLI..."), error = null) cli.login(client.token) } onConnect(client, cli) @@ -106,7 +114,7 @@ class ConnectPage( } catch (ex: Exception) { val msg = humanizeConnectionError(url, settings.requireTokenAuth, ex) notify("Failed to configure ${url.host}", ex) - updateStatus("Failed to configure ${url.host}", msg) + updateStatus(context.i18n.pnotr("Failed to configure ${url.host}"), msg) } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index efe4279..56b2910 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -1,6 +1,10 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** @@ -10,6 +14,7 @@ import com.jetbrains.toolbox.api.ui.components.UiField * For now we just use this to display the deployment URL since we do not * support creating environments from the plugin. */ -class NewEnvironmentPage(private val deploymentURL: String?) : CoderPage(deploymentURL ?: "") { - override val fields: MutableList = mutableListOf() +class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : + CoderPage(context, deploymentURL) { + override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/kotlin/com/coder/toolbox/views/SignInPage.kt b/src/main/kotlin/com/coder/toolbox/views/SignInPage.kt index 2fdbf60..f6455ba 100644 --- a/src/main/kotlin/com/coder/toolbox/views/SignInPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/SignInPage.kt @@ -1,11 +1,14 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.settings.Source import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.net.URL /** @@ -15,10 +18,11 @@ import java.net.URL * enter their own. */ class SignInPage( + private val context: CoderToolboxContext, private val deploymentURL: Pair?, private val onSignIn: (deploymentURL: URL) -> Unit, -) : CoderPage("Sign In to Coder") { - private val urlField = TextField("Deployment URL", deploymentURL?.first ?: "", TextType.General) +) : CoderPage(context, context.i18n.ptrl("Sign In to Coder")) { + private val urlField = TextField(context.i18n.ptrl("Deployment URL"), deploymentURL?.first ?: "", TextType.General) /** * Fields for this page, displayed in order. @@ -26,24 +30,28 @@ class SignInPage( * TODO@JB: Fields are reset when you navigate back. * Ideally they remember what the user entered. */ - override val fields: MutableList = listOfNotNull( - urlField, - deploymentURL?.let { LabelField(deploymentURL.second.description("URL")) }, - errorField, - ).toMutableList() + override val fields: StateFlow> = MutableStateFlow( + listOfNotNull( + urlField, + deploymentURL?.let { LabelField(context.i18n.pnotr(deploymentURL.second.description("URL"))) }, + errorField, + ) + ) /** * Buttons displayed at the bottom of the page. */ - override val actionButtons: MutableList = mutableListOf( - Action("Sign In", closesPage = false) { submit() }, + override val actionButtons: StateFlow> = MutableStateFlow( + listOf( + Action(context.i18n.ptrl("Sign In"), closesPage = false) { submit() }, + ) ) /** * Call onSignIn with the URL, or error if blank. */ private fun submit() { - val urlRaw = urlField.text.value + val urlRaw = urlField.textState.value // Ensure the URL can be parsed. try { if (urlRaw.isBlank()) { diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt b/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt index d0da1fc..4c2b016 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.settings.Source import com.coder.toolbox.util.withPath import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription @@ -8,6 +9,8 @@ import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.net.URL /** @@ -17,11 +20,12 @@ import java.net.URL * enter their own. */ class TokenPage( + private val context: CoderToolboxContext, deploymentURL: URL, token: Pair?, private val onToken: ((token: String) -> Unit), -) : CoderPage("Enter your token") { - private val tokenField = TextField("Token", token?.first ?: "", TextType.General) +) : CoderPage(context, context.i18n.ptrl("Enter your token")) { + private val tokenField = TextField(context.i18n.ptrl("Token"), token?.first ?: "", TextType.General) /** * Fields for this page, displayed in order. @@ -29,22 +33,31 @@ class TokenPage( * TODO@JB: Fields are reset when you navigate back. * Ideally they remember what the user entered. */ - override val fields: MutableList = listOfNotNull( - tokenField, - LabelField( - token?.second?.description("token") - ?: "No existing token for ${deploymentURL.host} found.", - ), - // TODO@JB: The link text displays twice. - LinkField("Get a token", deploymentURL.withPath("/login?redirect=%2Fcli-auth").toString()), - errorField, - ).toMutableList() + override val fields: StateFlow> = MutableStateFlow( + listOfNotNull( + tokenField, + LabelField( + context.i18n.pnotr( + token?.second?.description("token") + ?: "No existing token for ${deploymentURL.host} found." + ), + ), + // TODO@JB: The link text displays twice. + LinkField( + context.i18n.ptrl("Get a token"), + deploymentURL.withPath("/login?redirect=%2Fcli-auth").toString() + ), + errorField, + ) + ) /** * Buttons displayed at the bottom of the page. */ - override val actionButtons: MutableList = mutableListOf( - Action("Connect", closesPage = false) { submit(tokenField.text.value) }, + override val actionButtons: StateFlow> = MutableStateFlow( + listOf( + Action(context.i18n.ptrl("Connect"), closesPage = false) { submit(tokenField.textState.value) }, + ) ) /** diff --git a/src/main/resources/extension.json b/src/main/resources/extension.json deleted file mode 100644 index 828c03c..0000000 --- a/src/main/resources/extension.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "com.coder.toolbox", - "version": "0.0.1", - "meta": { - "readableName": "Coder Toolbox", - "description": "This plugin connects your JetBrains IDE to Coder workspaces.", - "vendor": "Coder", - "url": "https://github.com/coder/coder-jetbrains-toolbox-plugin" - }, - "apiVersion": "0.3", - "compatibleVersionRange": { - "from": "2.6.0.0", - "to": "2.6.0.99999" - } -} diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po new file mode 100644 index 0000000..837e2a0 --- /dev/null +++ b/src/main/resources/localization/defaultMessages.po @@ -0,0 +1,146 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Ioan Faur , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Coder Toolbox 1.0\n" +"Report-Msgid-Bugs-To: jetbrains-plugins@coder.com\n" +"POT-Creation-Date: 2025-03-04 12:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Yes" +msgstr "" + +msgid "OK" +msgstr "" + +msgid "No" +msgstr "" + +msgid "Save" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Delete running workspace?" +msgstr "" + +msgid "Delete workspace?" +msgstr "" + +msgid "Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical." +msgstr "" + +msgid "All the information in this workspace will be lost, including all files, unsaved changes and historical." +msgstr "" + +msgid "Session Token" +msgstr "" + +msgid "Deployment URL" +msgstr "" + +msgid "Enter the full URL of your Coder deployment" +msgstr "" + +msgid "Confirm download URL" +msgstr "" + +msgid "Open web terminal" +msgstr "" + +msgid "Open in dashboard" +msgstr "" + +msgid "View template" +msgstr "" + +msgid "Start" +msgstr "" + +msgid "Stop" +msgstr "" + +msgid "Update" +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Coder Settings" +msgstr "" + +msgid "Binary source" +msgstr "" + +msgid "Binary directory" +msgstr "" + +msgid "Data directory" +msgstr "" + +msgid "Enable downloads" +msgstr "" + +msgid "Enable binary directory fallback" +msgstr "" + +msgid "Header command" +msgstr "" + +msgid "TLS cert path" +msgstr "" + +msgid "TLS key path" +msgstr "" + +msgid "TLS CA path" +msgstr "" + +msgid "TLS alternate hostname" +msgstr "" + +msgid "Disable autostart" +msgstr "" + +msgid "Connecting to Coder" +msgstr "" + +msgid "Checking Coder binary..." +msgstr "" + +msgid "Configuring CLI..." +msgstr "" + +msgid "Retry" +msgstr "" + +msgid "Sign In" +msgstr "" + +msgid "Sign In to Coder" +msgstr "" + +msgid "Enter your token" +msgstr "" + +msgid "Token" +msgstr "" + +msgid "Get a token" +msgstr "" + +msgid "Connect" +msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 6eef3e9..87b659a 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.cli +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ex.MissingVersionException import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException @@ -14,8 +15,17 @@ import com.coder.toolbox.util.escape import com.coder.toolbox.util.getOS import com.coder.toolbox.util.sha1 import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi import com.squareup.moshi.JsonEncodingException import com.sun.net.httpserver.HttpServer +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException @@ -34,6 +44,17 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue internal class CoderCLIManagerTest { + private val context = CoderToolboxContext( + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(), + mockk(), + mockk() + ) + /** * Return the contents of a script that contains the string. */ @@ -84,7 +105,7 @@ internal class CoderCLIManagerTest { @Test fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) - val ccm = CoderCLIManager(url) + val ccm = CoderCLIManager(url, context.logger) val ex = assertFailsWith( @@ -104,16 +125,17 @@ internal class CoderCLIManagerTest { dataDirectory = tmpdir.resolve("cli-data-dir").toString(), binaryDirectory = tmpdir.resolve("cli-bin-dir").toString(), ), + context.logger ) val url = URL("http://localhost") - val ccm1 = CoderCLIManager(url, settings) + val ccm1 = CoderCLIManager(url, context.logger, settings) assertEquals(settings.binSource(url), ccm1.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm1.coderConfigPath.parent) assertEquals(settings.binPath(url), ccm1.localBinaryPath) // Can force using data directory. - val ccm2 = CoderCLIManager(url, settings, true) + val ccm2 = CoderCLIManager(url, context.logger, settings, true) assertEquals(settings.binSource(url), ccm2.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm2.coderConfigPath.parent) assertEquals(settings.binPath(url, true), ccm2.localBinaryPath) @@ -129,10 +151,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( url, + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("cli-dir-fail-to-write").toString(), ), + context.logger ), ) @@ -161,10 +185,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( url.toURL(), + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("real-cli").toString(), ), + context.logger ), ) @@ -187,10 +213,12 @@ internal class CoderCLIManagerTest { var ccm = CoderCLIManager( url, + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("mock-cli").toString(), ), + context.logger, binaryName = "coder.bat", ), ) @@ -205,11 +233,13 @@ internal class CoderCLIManagerTest { ccm = CoderCLIManager( url, + context.logger, CoderSettings( CoderSettingsState( binarySource = "/bin/override", dataDirectory = tmpdir.resolve("mock-cli").toString(), ), + context.logger ), ) @@ -224,10 +254,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( URL("https://foo"), + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("does-not-exist").toString(), ), + context.logger ), ) @@ -238,15 +270,17 @@ internal class CoderCLIManagerTest { } @Test - fun testOverwitesWrongVersion() { + fun testOverwritesWrongVersion() { val (srv, url) = mockServer() val ccm = CoderCLIManager( url, + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("overwrite-cli").toString(), ), + context.logger ), ) @@ -276,10 +310,11 @@ internal class CoderCLIManagerTest { CoderSettingsState( dataDirectory = tmpdir.resolve("clobber-cli").toString(), ), + context.logger ) - val ccm1 = CoderCLIManager(url1, settings) - val ccm2 = CoderCLIManager(url2, settings) + val ccm1 = CoderCLIManager(url1, context.logger, settings) + val ccm2 = CoderCLIManager(url2, context.logger, settings) assertTrue(ccm1.download()) assertTrue(ccm2.download()) @@ -321,7 +356,12 @@ internal class CoderCLIManagerTest { SSHTest(listOf("foo-bar"), "existing-end", "replace-end", "no-blocks"), SSHTest(listOf("foo-bar"), "existing-end-no-newline", "replace-end-no-newline", "no-blocks"), SSHTest(listOf("foo-bar"), "existing-middle", "replace-middle", "no-blocks"), - SSHTest(listOf("foo-bar"), "existing-middle-and-unrelated", "replace-middle-ignore-unrelated", "no-related-blocks"), + SSHTest( + listOf("foo-bar"), + "existing-middle-and-unrelated", + "replace-middle-ignore-unrelated", + "no-related-blocks" + ), SSHTest(listOf("foo-bar"), "existing-only", "replace-only", "blank"), SSHTest(listOf("foo-bar"), "existing-start", "replace-start", "no-blocks"), SSHTest(listOf("foo-bar"), "no-blocks", "append-no-blocks", "no-blocks"), @@ -404,17 +444,18 @@ internal class CoderCLIManagerTest { sshConfigOptions = it.extraConfig, sshLogDirectory = it.sshLogDirectory?.toString() ?: "", ), + context.logger, sshConfigPath = tmpdir.resolve(it.input + "_to_" + it.output + ".conf"), env = it.env, ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), settings) + val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) // Input is the configuration that we start with, if any. if (it.input != null) { settings.sshConfigPath.parent.toFile().mkdirs() val originalConf = - Path.of("src/test/fixtures/inputs").resolve(it.input + ".conf").toFile().readText() + Path.of("src/test/resources/fixtures/inputs").resolve(it.input + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) settings.sshConfigPath.toFile().writeText(originalConf) } @@ -422,10 +463,13 @@ internal class CoderCLIManagerTest { // Output is the configuration we expect to have after configuring. val coderConfigPath = ccm.localBinaryPath.parent.resolve("config") val expectedConf = - Path.of("src/test/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText() + Path.of("src/test/resources/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) .replace("/tmp/coder-toolbox/test.coder.invalid/config", escape(coderConfigPath.toString())) - .replace("/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString())) + .replace( + "/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64", + escape(ccm.localBinaryPath.toString()) + ) .let { conf -> if (it.sshLogDirectory != null) { conf.replace("/tmp/coder-toolbox/test.coder.invalid/logs", it.sshLogDirectory.toString()) @@ -450,7 +494,7 @@ internal class CoderCLIManagerTest { // Remove is the configuration we expect after removing. assertEquals( settings.sshConfigPath.toFile().readText(), - Path.of("src/test/fixtures/inputs").resolve(it.remove + ".conf").toFile() + Path.of("src/test/resources/fixtures/inputs").resolve(it.remove + ".conf").toFile() .readText().replace(newlineRe, System.lineSeparator()), ) } @@ -470,15 +514,16 @@ internal class CoderCLIManagerTest { val settings = CoderSettings( CoderSettingsState(), + context.logger, sshConfigPath = tmpdir.resolve("configured$it.conf"), ) settings.sshConfigPath.parent.toFile().mkdirs() - Path.of("src/test/fixtures/inputs").resolve("$it.conf").toFile().copyTo( + Path.of("src/test/resources/fixtures/inputs").resolve("$it.conf").toFile().copyTo( settings.sshConfigPath.toFile(), true, ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), settings) + val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) assertFailsWith( exceptionClass = SSHConfigFormatException::class, @@ -498,10 +543,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( URL("https://test.coder.invalid"), + context.logger, CoderSettings( CoderSettingsState( headerCommand = it, ), + context.logger ), ) @@ -547,10 +594,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( URL("https://test.coder.parse-fail.invalid"), + context.logger, CoderSettings( CoderSettingsState( binaryDirectory = tmpdir.resolve("bad-version").toString(), ), + context.logger, binaryName = "coder.bat", ), ) @@ -598,10 +647,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( URL("https://test.coder.matches-version.invalid"), + context.logger, CoderSettings( CoderSettingsState( binaryDirectory = tmpdir.resolve("matches-version").toString(), ), + context.logger, binaryName = "coder.bat", ), ) @@ -667,8 +718,24 @@ internal class CoderCLIManagerTest { EnsureCLITest(null, null, "1.0.0", false, true, true, Result.DL_DATA), // Download to fallback. EnsureCLITest(null, null, "1.0.0", false, false, true, Result.NONE), // No download, error when used. EnsureCLITest("1.0.1", "1.0.1", "1.0.0", false, true, true, Result.DL_DATA), // Update fallback. - EnsureCLITest("1.0.1", "1.0.2", "1.0.0", false, false, true, Result.USE_BIN), // No update, use outdated. - EnsureCLITest(null, "1.0.2", "1.0.0", false, false, true, Result.USE_DATA), // No update, use outdated fallback. + EnsureCLITest( + "1.0.1", + "1.0.2", + "1.0.0", + false, + false, + true, + Result.USE_BIN + ), // No update, use outdated. + EnsureCLITest( + null, + "1.0.2", + "1.0.0", + false, + false, + true, + Result.USE_DATA + ), // No update, use outdated fallback. EnsureCLITest("1.0.0", null, "1.0.0", false, false, true, Result.USE_BIN), // Use existing. EnsureCLITest("1.0.1", "1.0.0", "1.0.0", false, false, true, Result.USE_DATA), // Use existing fallback. ) @@ -684,6 +751,7 @@ internal class CoderCLIManagerTest { dataDirectory = tmpdir.resolve("ensure-data-dir").toString(), binaryDirectory = tmpdir.resolve("ensure-bin-dir").toString(), ), + context.logger ) // Clean up from previous test. @@ -714,34 +782,39 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(url, it.buildVersion, settings) }, + block = { ensureCLI(context, url, it.buildVersion, settings) }, ) } + Result.NONE -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = ensureCLI(context, url, it.buildVersion, settings) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, block = { ccm.version() }, ) } + Result.DL_BIN -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = ensureCLI(context, url, it.buildVersion, settings) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } + Result.DL_DATA -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = ensureCLI(context, url, it.buildVersion, settings) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } + Result.USE_BIN -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = ensureCLI(context, url, it.buildVersion, settings) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } + Result.USE_DATA -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = ensureCLI(context, url, it.buildVersion, settings) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -772,10 +845,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( url, + context.logger, CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("features").toString(), ), + context.logger, binaryName = "coder.bat", ), ) @@ -787,7 +862,8 @@ internal class CoderCLIManagerTest { } companion object { - private val tmpdir: Path = Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-toolbox-test/cli-manager") + private val tmpdir: Path = + Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-toolbox-test/cli-manager") @JvmStatic @BeforeAll diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/FakeLogger.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/FakeLogger.kt new file mode 100644 index 0000000..abcbee1 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/FakeLogger.kt @@ -0,0 +1,73 @@ +package com.coder.toolbox.diagnostics + +import com.jetbrains.toolbox.api.core.diagnostics.Logger + +class FakeLogger : Logger { + override fun error(exception: Throwable, message: () -> String) { + + } + + override fun error(exception: Throwable, message: String) { + } + + override fun error(message: () -> String) { + + } + + override fun error(message: String) { + + } + + override fun warn(exception: Throwable, message: () -> String) { + + } + + override fun warn(exception: Throwable, message: String) { + + } + + override fun warn(message: () -> String) { + + } + + override fun warn(message: String) { + + } + + override fun debug(exception: Throwable, message: () -> String) { + } + + override fun debug(exception: Throwable, message: String) { + } + + override fun debug(message: () -> String) { + } + + override fun debug(message: String) { + } + + override fun info(exception: Throwable, message: () -> String) { + } + + override fun info(exception: Throwable, message: String) { + } + + override fun info(message: () -> String) { + } + + override fun info(message: String) { + } + + override fun trace(exception: Throwable, message: () -> String) { + } + + override fun trace(exception: Throwable, message: String) { + } + + override fun trace(message: () -> String) { + } + + override fun trace(message: String) { + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 53fd633..78a2ea1 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException @@ -15,6 +16,13 @@ import com.coder.toolbox.sdk.v2.models.WorkspacesResponse import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.CoderSettingsState import com.coder.toolbox.util.sslContextFromPEMs +import com.jetbrains.toolbox.api.core.PluginSecretStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.sun.net.httpserver.HttpExchange @@ -22,6 +30,8 @@ import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpsConfigurator import com.sun.net.httpserver.HttpsServer +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope import okio.buffer import okio.source import java.io.IOException @@ -81,6 +91,17 @@ class CoderRestClientTest { .add(UUIDConverter()) .build() + private val context = CoderToolboxContext( + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(), + mockk(), + mockk() + ) + data class TestWorkspace(var workspace: Workspace, var resources: List? = emptyList()) /** @@ -96,8 +117,8 @@ class CoderRestClientTest { val srv = HttpsServer.create(InetSocketAddress(0), 0) val sslContext = sslContextFromPEMs( - Path.of("src/test/fixtures/tls", "$certName.crt").toString(), - Path.of("src/test/fixtures/tls", "$certName.key").toString(), + Path.of("src/test/resources/fixtures/tls", "$certName.crt").toString(), + Path.of("src/test/resources/fixtures/tls", "$certName.key").toString(), "", ) srv.httpsConfigurator = HttpsConfigurator(sslContext) @@ -138,7 +159,7 @@ class CoderRestClientTest { ) tests.forEach { (endpoint, block) -> val (srv, url) = mockServer() - val client = CoderRestClient(URL(url), "token") + val client = CoderRestClient(context, URL(url), "token") srv.createContext( endpoint, BaseHttpHandler("GET") { exchange -> @@ -178,7 +199,7 @@ class CoderRestClientTest { }, ) - val client = CoderRestClient(URL(url), "token") + val client = CoderRestClient(context, URL(url), "token") assertEquals(user.username, client.me().username) val tests = listOf("invalid", null) @@ -186,7 +207,7 @@ class CoderRestClientTest { val ex = assertFailsWith( exceptionClass = APIResponseException::class, - block = { CoderRestClient(URL(url), token).me() }, + block = { CoderRestClient(context, URL(url), token).me() }, ) assertEquals(true, ex.isUnauthorized) } @@ -207,7 +228,7 @@ class CoderRestClientTest { ) tests.forEach { workspaces -> val (srv, url) = mockServer() - val client = CoderRestClient(URL(url), "token") + val client = CoderRestClient(context, URL(url), "token") srv.createContext( "/api/v2/workspaces", BaseHttpHandler("GET") { exchange -> @@ -229,31 +250,44 @@ class CoderRestClientTest { // Nothing, so no resources. emptyList(), // One workspace with an agent, but no resources. - listOf(TestWorkspace(DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))), + listOf( + TestWorkspace( + DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ) + ) + ), // One workspace with an agent and resources that do not match the agent. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), - resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") ), + resources = + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), ), // Multiple workspaces but only one has resources. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ), resources = emptyList(), ), TestWorkspace( workspace = DataGen.workspace("ws2"), resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), - ), + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), TestWorkspace( workspace = DataGen.workspace("ws3"), @@ -265,14 +299,15 @@ class CoderRestClientTest { val resourceEndpoint = "([^/]+)/resources".toRegex() tests.forEach { workspaces -> val (srv, url) = mockServer() - val client = CoderRestClient(URL(url), "token") + val client = CoderRestClient(context, URL(url), "token") srv.createContext( "/api/v2/templateversions", BaseHttpHandler("GET") { exchange -> val matches = resourceEndpoint.find(exchange.requestURI.path) if (matches != null) { val templateVersionId = UUID.fromString(matches.destructured.toList()[0]) - val ws = workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } + val ws = + workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } if (ws != null) { val body = moshi.adapter>( @@ -301,7 +336,7 @@ class CoderRestClientTest { val actions = mutableListOf>() val (srv, url) = mockServer() - val client = CoderRestClient(URL(url), "token") + val client = CoderRestClient(context, URL(url), "token") val templateEndpoint = "/api/v2/templates/([^/]+)".toRegex() srv.createContext( "/api/v2/templates", @@ -326,7 +361,8 @@ class CoderRestClientTest { val buildMatch = buildEndpoint.find(exchange.requestURI.path) if (buildMatch != null) { val workspaceId = UUID.fromString(buildMatch.destructured.toList()[0]) - val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java).fromJson(exchange.requestBody.source().buffer()) + val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java) + .fromJson(exchange.requestBody.source().buffer()) if (json == null) { val response = Response("No body", "No body for create workspace build request") val body = moshi.adapter(Response::class.java).toJson(response).toByteArray() @@ -395,13 +431,14 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), + tlsCAPath = Path.of("src/test/resources/fixtures/tls", "self-signed.crt").toString(), tlsAlternateHostname = "localhost", ), + context.logger ) val user = DataGen.user() val (srv, url) = mockTLSServer("self-signed") - val client = CoderRestClient(URL(url), "token", settings) + val client = CoderRestClient(context, URL(url), "token", settings) srv.createContext( "/api/v2/users/me", BaseHttpHandler("GET") { exchange -> @@ -421,12 +458,13 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), + tlsCAPath = Path.of("src/test/resources/fixtures/tls", "self-signed.crt").toString(), tlsAlternateHostname = "fake.example.com", ), + context.logger ) val (srv, url) = mockTLSServer("self-signed") - val client = CoderRestClient(URL(url), "token", settings) + val client = CoderRestClient(context, URL(url), "token", settings) assertFailsWith( exceptionClass = SSLPeerUnverifiedException::class, @@ -441,11 +479,12 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), + tlsCAPath = Path.of("src/test/resources/fixtures/tls", "self-signed.crt").toString(), ), + context.logger ) val (srv, url) = mockTLSServer("no-signing") - val client = CoderRestClient(URL(url), "token", settings) + val client = CoderRestClient(context, URL(url), "token", settings) assertFailsWith( exceptionClass = SSLHandshakeException::class, @@ -460,12 +499,13 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "chain-root.crt").toString(), + tlsCAPath = Path.of("src/test/resources/fixtures/tls", "chain-root.crt").toString(), ), + context.logger ) val user = DataGen.user() val (srv, url) = mockTLSServer("chain") - val client = CoderRestClient(URL(url), "token", settings) + val client = CoderRestClient(context, URL(url), "token", settings) srv.createContext( "/api/v2/users/me", BaseHttpHandler("GET") { exchange -> @@ -482,7 +522,7 @@ class CoderRestClientTest { @Test fun usesProxy() { - val settings = CoderSettings(CoderSettingsState()) + val settings = CoderSettings(CoderSettingsState(), context.logger) val workspaces = listOf(DataGen.workspace("ws1")) val (srv1, url1) = mockServer() srv1.createContext( @@ -497,6 +537,7 @@ class CoderRestClientTest { val srv2 = mockProxy() val client = CoderRestClient( + context, URL(url1), "token", settings, @@ -505,7 +546,8 @@ class CoderRestClientTest { "bar", true, object : ProxySelector() { - override fun select(uri: URI): List = listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) override fun connectFailed( uri: URI, diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 7e6e3f1..6a3e69e 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -3,6 +3,8 @@ package com.coder.toolbox.settings import com.coder.toolbox.util.OS import com.coder.toolbox.util.getOS import com.coder.toolbox.util.withPath +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk import java.net.URL import java.nio.file.Path import kotlin.test.Test @@ -11,10 +13,12 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals internal class CoderSettingsTest { + private val logger = mockk(relaxed = true) + @Test fun testExpands() { val state = CoderSettingsState() - val settings = CoderSettings(state) + val settings = CoderSettings(state, logger) val url = URL("http://localhost") val home = Path.of(System.getProperty("user.home")) @@ -33,9 +37,8 @@ internal class CoderSettingsTest { val url = URL("http://localhost") var settings = CoderSettings( - state, - env = - Environment( + state, logger, + env = Environment( mapOf( "LOCALAPPDATA" to "/tmp/coder-toolbox-test/localappdata", "HOME" to "/tmp/coder-toolbox-test/home", @@ -57,14 +60,14 @@ internal class CoderSettingsTest { if (getOS() == OS.LINUX) { settings = CoderSettings( - state, + state, logger, env = - Environment( - mapOf( - "XDG_DATA_HOME" to "", - "HOME" to "/tmp/coder-toolbox-test/home", + Environment( + mapOf( + "XDG_DATA_HOME" to "", + "HOME" to "/tmp/coder-toolbox-test/home", + ), ), - ), ) expected = "/tmp/coder-toolbox-test/home/.local/share/coder-toolbox/localhost" @@ -76,15 +79,15 @@ internal class CoderSettingsTest { state.dataDirectory = "/tmp/coder-toolbox-test/data-dir" settings = CoderSettings( - state, + state, logger, env = - Environment( - mapOf( - "LOCALAPPDATA" to "/ignore", - "HOME" to "/ignore", - "XDG_DATA_HOME" to "/ignore", + Environment( + mapOf( + "LOCALAPPDATA" to "/ignore", + "HOME" to "/ignore", + "XDG_DATA_HOME" to "/ignore", + ), ), - ), ) expected = "/tmp/coder-toolbox-test/data-dir/localhost" assertEquals(Path.of(expected).toAbsolutePath(), settings.dataDir(url)) @@ -93,7 +96,7 @@ internal class CoderSettingsTest { // Check that the URL is encoded and includes the port, also omit environment. val newUrl = URL("https://dev.😉-coder.com:8080") state.dataDirectory = "/tmp/coder-toolbox-test/data-dir" - settings = CoderSettings(state) + settings = CoderSettings(state, logger) expected = "/tmp/coder-toolbox-test/data-dir/dev.xn---coder-vx74e.com-8080" assertEquals(Path.of(expected).toAbsolutePath(), settings.dataDir(newUrl)) assertEquals(Path.of(expected).toAbsolutePath(), settings.binPath(newUrl).parent) @@ -102,8 +105,8 @@ internal class CoderSettingsTest { @Test fun testBinPath() { val state = CoderSettingsState() - val settings = CoderSettings(state) - val settings2 = CoderSettings(state, binaryName = "foo-bar.baz") + val settings = CoderSettings(state, logger) + val settings2 = CoderSettings(state, logger, binaryName = "foo-bar.baz") // The binary path should fall back to the data directory but that is // already tested in the data directory tests. val url = URL("http://localhost") @@ -129,15 +132,15 @@ internal class CoderSettingsTest { val state = CoderSettingsState() var settings = CoderSettings( - state, + state, logger, env = - Environment( - mapOf( - "APPDATA" to "/tmp/coder-toolbox-test/cli-appdata", - "HOME" to "/tmp/coder-toolbox-test/cli-home", - "XDG_CONFIG_HOME" to "/tmp/coder-toolbox-test/cli-xdg-config", + Environment( + mapOf( + "APPDATA" to "/tmp/coder-toolbox-test/cli-appdata", + "HOME" to "/tmp/coder-toolbox-test/cli-home", + "XDG_CONFIG_HOME" to "/tmp/coder-toolbox-test/cli-xdg-config", + ), ), - ), ) var expected = when (getOS()) { @@ -151,14 +154,14 @@ internal class CoderSettingsTest { if (getOS() == OS.LINUX) { settings = CoderSettings( - state, + state, logger, env = - Environment( - mapOf( - "XDG_CONFIG_HOME" to "", - "HOME" to "/tmp/coder-toolbox-test/cli-home", + Environment( + mapOf( + "XDG_CONFIG_HOME" to "", + "HOME" to "/tmp/coder-toolbox-test/cli-home", + ), ), - ), ) expected = "/tmp/coder-toolbox-test/cli-home/.config/coderv2" assertEquals(Path.of(expected), settings.coderConfigDir) @@ -167,16 +170,16 @@ internal class CoderSettingsTest { // Read CODER_CONFIG_DIR. settings = CoderSettings( - state, + state, logger, env = - Environment( - mapOf( - "CODER_CONFIG_DIR" to "/tmp/coder-toolbox-test/coder-config-dir", - "APPDATA" to "/ignore", - "HOME" to "/ignore", - "XDG_CONFIG_HOME" to "/ignore", + Environment( + mapOf( + "CODER_CONFIG_DIR" to "/tmp/coder-toolbox-test/coder-config-dir", + "APPDATA" to "/ignore", + "HOME" to "/ignore", + "XDG_CONFIG_HOME" to "/ignore", + ), ), - ), ) expected = "/tmp/coder-toolbox-test/coder-config-dir" assertEquals(Path.of(expected), settings.coderConfigDir) @@ -185,7 +188,7 @@ internal class CoderSettingsTest { @Test fun binSource() { val state = CoderSettingsState() - val settings = CoderSettings(state) + val settings = CoderSettings(state, logger) // As-is if no source override. val url = URL("http://localhost/") assertContains( @@ -212,23 +215,24 @@ internal class CoderSettingsTest { expected.resolve("url").toFile().writeText("http://test.toolbox.coder.com$expected") expected.resolve("session").toFile().writeText("fake-token") - var got = CoderSettings(CoderSettingsState()).readConfig(expected) + var got = CoderSettings(CoderSettingsState(), logger).readConfig(expected) assertEquals(Pair("http://test.toolbox.coder.com$expected", "fake-token"), got) // Ignore token if missing. expected.resolve("session").toFile().delete() - got = CoderSettings(CoderSettingsState()).readConfig(expected) + got = CoderSettings(CoderSettingsState(), logger).readConfig(expected) assertEquals(Pair("http://test.toolbox.coder.com$expected", null), got) } @Test fun testSSHConfigOptions() { - var settings = CoderSettings(CoderSettingsState(sshConfigOptions = "ssh config options from state")) + var settings = CoderSettings(CoderSettingsState(sshConfigOptions = "ssh config options from state"), logger) assertEquals("ssh config options from state", settings.sshConfigOptions) settings = CoderSettings( CoderSettingsState(), + logger, env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to "ssh config options from env")), ) assertEquals("ssh config options from env", settings.sshConfigOptions) @@ -237,6 +241,7 @@ internal class CoderSettingsTest { settings = CoderSettings( CoderSettingsState(sshConfigOptions = "ssh config options from state"), + logger, env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to "ssh config options from env")), ) assertEquals("ssh config options from state", settings.sshConfigOptions) @@ -244,16 +249,16 @@ internal class CoderSettingsTest { @Test fun testRequireTokenAuth() { - var settings = CoderSettings(CoderSettingsState()) + var settings = CoderSettings(CoderSettingsState(), logger) assertEquals(true, settings.requireTokenAuth) - settings = CoderSettings(CoderSettingsState(tlsCertPath = "cert path")) + settings = CoderSettings(CoderSettingsState(tlsCertPath = "cert path"), logger) assertEquals(true, settings.requireTokenAuth) - settings = CoderSettings(CoderSettingsState(tlsKeyPath = "key path")) + settings = CoderSettings(CoderSettingsState(tlsKeyPath = "key path"), logger) assertEquals(true, settings.requireTokenAuth) - settings = CoderSettings(CoderSettingsState(tlsCertPath = "cert path", tlsKeyPath = "key path")) + settings = CoderSettings(CoderSettingsState(tlsCertPath = "cert path", tlsKeyPath = "key path"), logger) assertEquals(false, settings.requireTokenAuth) } @@ -265,14 +270,14 @@ internal class CoderSettingsTest { dir.toFile().deleteRecursively() // No config. - var settings = CoderSettings(CoderSettingsState(), env = env) + var settings = CoderSettings(CoderSettingsState(), logger, env = env) assertEquals(null, settings.defaultURL()) // Read from global config. val globalConfigPath = settings.coderConfigDir globalConfigPath.toFile().mkdirs() globalConfigPath.resolve("url").toFile().writeText("url-from-global-config") - settings = CoderSettings(CoderSettingsState(), env = env) + settings = CoderSettings(CoderSettingsState(), logger, env = env) assertEquals("url-from-global-config" to Source.CONFIG, settings.defaultURL()) // Read from environment. @@ -283,7 +288,7 @@ internal class CoderSettingsTest { "CODER_CONFIG_DIR" to dir.toString(), ), ) - settings = CoderSettings(CoderSettingsState(), env = env) + settings = CoderSettings(CoderSettingsState(), logger, env = env) assertEquals("url-from-env" to Source.ENVIRONMENT, settings.defaultURL()) // Read from settings. @@ -292,6 +297,7 @@ internal class CoderSettingsTest { CoderSettingsState( defaultURL = "url-from-settings", ), + logger, env = env, ) assertEquals("url-from-settings" to Source.SETTINGS, settings.defaultURL()) @@ -314,7 +320,7 @@ internal class CoderSettingsTest { dir.toFile().deleteRecursively() // No config. - var settings = CoderSettings(CoderSettingsState(), env = env) + var settings = CoderSettings(CoderSettingsState(), logger, env = env) assertEquals(null, settings.token(url)) val globalConfigPath = settings.coderConfigDir @@ -349,6 +355,7 @@ internal class CoderSettingsTest { tlsKeyPath = "key", tlsCertPath = "cert", ), + logger, env = env, ) assertEquals(null, settings.token(url)) @@ -357,7 +364,7 @@ internal class CoderSettingsTest { @Test fun testDefaults() { // Test defaults for the remaining settings. - val settings = CoderSettings(CoderSettingsState()) + val settings = CoderSettings(CoderSettingsState(), logger) assertEquals(true, settings.enableDownloads) assertEquals(false, settings.enableBinaryDirectoryFallback) assertEquals("", settings.headerCommand) @@ -388,6 +395,7 @@ internal class CoderSettingsTest { ignoreSetupFailure = true, sshLogDirectory = "test ssh log directory", ), + logger, ) assertEquals(false, settings.enableDownloads) diff --git a/src/test/fixtures/inputs/blank-newlines.conf b/src/test/resources/fixtures/inputs/blank-newlines.conf similarity index 100% rename from src/test/fixtures/inputs/blank-newlines.conf rename to src/test/resources/fixtures/inputs/blank-newlines.conf diff --git a/src/test/fixtures/inputs/blank.conf b/src/test/resources/fixtures/inputs/blank.conf similarity index 100% rename from src/test/fixtures/inputs/blank.conf rename to src/test/resources/fixtures/inputs/blank.conf diff --git a/src/test/fixtures/inputs/existing-end-no-newline.conf b/src/test/resources/fixtures/inputs/existing-end-no-newline.conf similarity index 100% rename from src/test/fixtures/inputs/existing-end-no-newline.conf rename to src/test/resources/fixtures/inputs/existing-end-no-newline.conf diff --git a/src/test/fixtures/inputs/existing-end.conf b/src/test/resources/fixtures/inputs/existing-end.conf similarity index 100% rename from src/test/fixtures/inputs/existing-end.conf rename to src/test/resources/fixtures/inputs/existing-end.conf diff --git a/src/test/fixtures/inputs/existing-middle-and-unrelated.conf b/src/test/resources/fixtures/inputs/existing-middle-and-unrelated.conf similarity index 100% rename from src/test/fixtures/inputs/existing-middle-and-unrelated.conf rename to src/test/resources/fixtures/inputs/existing-middle-and-unrelated.conf diff --git a/src/test/fixtures/inputs/existing-middle.conf b/src/test/resources/fixtures/inputs/existing-middle.conf similarity index 100% rename from src/test/fixtures/inputs/existing-middle.conf rename to src/test/resources/fixtures/inputs/existing-middle.conf diff --git a/src/test/fixtures/inputs/existing-only.conf b/src/test/resources/fixtures/inputs/existing-only.conf similarity index 100% rename from src/test/fixtures/inputs/existing-only.conf rename to src/test/resources/fixtures/inputs/existing-only.conf diff --git a/src/test/fixtures/inputs/existing-start.conf b/src/test/resources/fixtures/inputs/existing-start.conf similarity index 100% rename from src/test/fixtures/inputs/existing-start.conf rename to src/test/resources/fixtures/inputs/existing-start.conf diff --git a/src/test/fixtures/inputs/malformed-mismatched-start.conf b/src/test/resources/fixtures/inputs/malformed-mismatched-start.conf similarity index 100% rename from src/test/fixtures/inputs/malformed-mismatched-start.conf rename to src/test/resources/fixtures/inputs/malformed-mismatched-start.conf diff --git a/src/test/fixtures/inputs/malformed-no-end.conf b/src/test/resources/fixtures/inputs/malformed-no-end.conf similarity index 100% rename from src/test/fixtures/inputs/malformed-no-end.conf rename to src/test/resources/fixtures/inputs/malformed-no-end.conf diff --git a/src/test/fixtures/inputs/malformed-no-start.conf b/src/test/resources/fixtures/inputs/malformed-no-start.conf similarity index 100% rename from src/test/fixtures/inputs/malformed-no-start.conf rename to src/test/resources/fixtures/inputs/malformed-no-start.conf diff --git a/src/test/fixtures/inputs/malformed-start-after-end.conf b/src/test/resources/fixtures/inputs/malformed-start-after-end.conf similarity index 100% rename from src/test/fixtures/inputs/malformed-start-after-end.conf rename to src/test/resources/fixtures/inputs/malformed-start-after-end.conf diff --git a/src/test/fixtures/inputs/no-blocks.conf b/src/test/resources/fixtures/inputs/no-blocks.conf similarity index 100% rename from src/test/fixtures/inputs/no-blocks.conf rename to src/test/resources/fixtures/inputs/no-blocks.conf diff --git a/src/test/fixtures/inputs/no-newline.conf b/src/test/resources/fixtures/inputs/no-newline.conf similarity index 100% rename from src/test/fixtures/inputs/no-newline.conf rename to src/test/resources/fixtures/inputs/no-newline.conf diff --git a/src/test/fixtures/inputs/no-related-blocks.conf b/src/test/resources/fixtures/inputs/no-related-blocks.conf similarity index 100% rename from src/test/fixtures/inputs/no-related-blocks.conf rename to src/test/resources/fixtures/inputs/no-related-blocks.conf diff --git a/src/test/fixtures/outputs/append-blank-newlines.conf b/src/test/resources/fixtures/outputs/append-blank-newlines.conf similarity index 100% rename from src/test/fixtures/outputs/append-blank-newlines.conf rename to src/test/resources/fixtures/outputs/append-blank-newlines.conf diff --git a/src/test/fixtures/outputs/append-blank.conf b/src/test/resources/fixtures/outputs/append-blank.conf similarity index 100% rename from src/test/fixtures/outputs/append-blank.conf rename to src/test/resources/fixtures/outputs/append-blank.conf diff --git a/src/test/fixtures/outputs/append-no-blocks.conf b/src/test/resources/fixtures/outputs/append-no-blocks.conf similarity index 100% rename from src/test/fixtures/outputs/append-no-blocks.conf rename to src/test/resources/fixtures/outputs/append-no-blocks.conf diff --git a/src/test/fixtures/outputs/append-no-newline.conf b/src/test/resources/fixtures/outputs/append-no-newline.conf similarity index 100% rename from src/test/fixtures/outputs/append-no-newline.conf rename to src/test/resources/fixtures/outputs/append-no-newline.conf diff --git a/src/test/fixtures/outputs/append-no-related-blocks.conf b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf similarity index 100% rename from src/test/fixtures/outputs/append-no-related-blocks.conf rename to src/test/resources/fixtures/outputs/append-no-related-blocks.conf diff --git a/src/test/fixtures/outputs/disable-autostart.conf b/src/test/resources/fixtures/outputs/disable-autostart.conf similarity index 100% rename from src/test/fixtures/outputs/disable-autostart.conf rename to src/test/resources/fixtures/outputs/disable-autostart.conf diff --git a/src/test/fixtures/outputs/extra-config.conf b/src/test/resources/fixtures/outputs/extra-config.conf similarity index 100% rename from src/test/fixtures/outputs/extra-config.conf rename to src/test/resources/fixtures/outputs/extra-config.conf diff --git a/src/test/fixtures/outputs/header-command-windows.conf b/src/test/resources/fixtures/outputs/header-command-windows.conf similarity index 100% rename from src/test/fixtures/outputs/header-command-windows.conf rename to src/test/resources/fixtures/outputs/header-command-windows.conf diff --git a/src/test/fixtures/outputs/header-command.conf b/src/test/resources/fixtures/outputs/header-command.conf similarity index 100% rename from src/test/fixtures/outputs/header-command.conf rename to src/test/resources/fixtures/outputs/header-command.conf diff --git a/src/test/fixtures/outputs/log-dir.conf b/src/test/resources/fixtures/outputs/log-dir.conf similarity index 100% rename from src/test/fixtures/outputs/log-dir.conf rename to src/test/resources/fixtures/outputs/log-dir.conf diff --git a/src/test/fixtures/outputs/multiple-workspaces.conf b/src/test/resources/fixtures/outputs/multiple-workspaces.conf similarity index 100% rename from src/test/fixtures/outputs/multiple-workspaces.conf rename to src/test/resources/fixtures/outputs/multiple-workspaces.conf diff --git a/src/test/fixtures/outputs/no-disable-autostart.conf b/src/test/resources/fixtures/outputs/no-disable-autostart.conf similarity index 100% rename from src/test/fixtures/outputs/no-disable-autostart.conf rename to src/test/resources/fixtures/outputs/no-disable-autostart.conf diff --git a/src/test/fixtures/outputs/no-report-usage.conf b/src/test/resources/fixtures/outputs/no-report-usage.conf similarity index 100% rename from src/test/fixtures/outputs/no-report-usage.conf rename to src/test/resources/fixtures/outputs/no-report-usage.conf diff --git a/src/test/fixtures/outputs/replace-end-no-newline.conf b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf similarity index 100% rename from src/test/fixtures/outputs/replace-end-no-newline.conf rename to src/test/resources/fixtures/outputs/replace-end-no-newline.conf diff --git a/src/test/fixtures/outputs/replace-end.conf b/src/test/resources/fixtures/outputs/replace-end.conf similarity index 100% rename from src/test/fixtures/outputs/replace-end.conf rename to src/test/resources/fixtures/outputs/replace-end.conf diff --git a/src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf similarity index 100% rename from src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf rename to src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf diff --git a/src/test/fixtures/outputs/replace-middle.conf b/src/test/resources/fixtures/outputs/replace-middle.conf similarity index 100% rename from src/test/fixtures/outputs/replace-middle.conf rename to src/test/resources/fixtures/outputs/replace-middle.conf diff --git a/src/test/fixtures/outputs/replace-only.conf b/src/test/resources/fixtures/outputs/replace-only.conf similarity index 100% rename from src/test/fixtures/outputs/replace-only.conf rename to src/test/resources/fixtures/outputs/replace-only.conf diff --git a/src/test/fixtures/outputs/replace-start.conf b/src/test/resources/fixtures/outputs/replace-start.conf similarity index 100% rename from src/test/fixtures/outputs/replace-start.conf rename to src/test/resources/fixtures/outputs/replace-start.conf diff --git a/src/test/fixtures/tls/chain-intermediate.crt b/src/test/resources/fixtures/tls/chain-intermediate.crt similarity index 100% rename from src/test/fixtures/tls/chain-intermediate.crt rename to src/test/resources/fixtures/tls/chain-intermediate.crt diff --git a/src/test/fixtures/tls/chain-intermediate.key b/src/test/resources/fixtures/tls/chain-intermediate.key similarity index 100% rename from src/test/fixtures/tls/chain-intermediate.key rename to src/test/resources/fixtures/tls/chain-intermediate.key diff --git a/src/test/fixtures/tls/chain-leaf.crt b/src/test/resources/fixtures/tls/chain-leaf.crt similarity index 100% rename from src/test/fixtures/tls/chain-leaf.crt rename to src/test/resources/fixtures/tls/chain-leaf.crt diff --git a/src/test/fixtures/tls/chain-leaf.key b/src/test/resources/fixtures/tls/chain-leaf.key similarity index 100% rename from src/test/fixtures/tls/chain-leaf.key rename to src/test/resources/fixtures/tls/chain-leaf.key diff --git a/src/test/fixtures/tls/chain-root.crt b/src/test/resources/fixtures/tls/chain-root.crt similarity index 100% rename from src/test/fixtures/tls/chain-root.crt rename to src/test/resources/fixtures/tls/chain-root.crt diff --git a/src/test/fixtures/tls/chain-root.key b/src/test/resources/fixtures/tls/chain-root.key similarity index 100% rename from src/test/fixtures/tls/chain-root.key rename to src/test/resources/fixtures/tls/chain-root.key diff --git a/src/test/fixtures/tls/chain.crt b/src/test/resources/fixtures/tls/chain.crt similarity index 100% rename from src/test/fixtures/tls/chain.crt rename to src/test/resources/fixtures/tls/chain.crt diff --git a/src/test/fixtures/tls/chain.key b/src/test/resources/fixtures/tls/chain.key similarity index 100% rename from src/test/fixtures/tls/chain.key rename to src/test/resources/fixtures/tls/chain.key diff --git a/src/test/fixtures/tls/generate.bash b/src/test/resources/fixtures/tls/generate.bash similarity index 100% rename from src/test/fixtures/tls/generate.bash rename to src/test/resources/fixtures/tls/generate.bash diff --git a/src/test/fixtures/tls/no-signing.crt b/src/test/resources/fixtures/tls/no-signing.crt similarity index 100% rename from src/test/fixtures/tls/no-signing.crt rename to src/test/resources/fixtures/tls/no-signing.crt diff --git a/src/test/fixtures/tls/no-signing.key b/src/test/resources/fixtures/tls/no-signing.key similarity index 100% rename from src/test/fixtures/tls/no-signing.key rename to src/test/resources/fixtures/tls/no-signing.key diff --git a/src/test/fixtures/tls/self-signed.crt b/src/test/resources/fixtures/tls/self-signed.crt similarity index 100% rename from src/test/fixtures/tls/self-signed.crt rename to src/test/resources/fixtures/tls/self-signed.crt diff --git a/src/test/fixtures/tls/self-signed.key b/src/test/resources/fixtures/tls/self-signed.key similarity index 100% rename from src/test/fixtures/tls/self-signed.key rename to src/test/resources/fixtures/tls/self-signed.key