Skip to content

Fix Coder connect workflows #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 26, 2025
28 changes: 0 additions & 28 deletions src/main/kotlin/com/coder/toolbox/CoderGatewayExtension.kt

This file was deleted.

37 changes: 27 additions & 10 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.coder.toolbox

import com.coder.toolbox.browser.BrowserUtil
import com.coder.toolbox.models.WorkspaceAndAgentStatus
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.v2.models.Workspace
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.environments.EnvironmentContentsView
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateConsumer
import com.jetbrains.toolbox.api.ui.ToolboxUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture

/**
Expand All @@ -19,32 +24,44 @@ import java.util.concurrent.CompletableFuture
* Used in the environment list view.
*/
class CoderRemoteEnvironment(
private val serviceLocator: ServiceLocator,
private val client: CoderRestClient,
private var workspace: Workspace,
private var agent: WorkspaceAgent,
private val ui: ToolboxUi,
private var cs: CoroutineScope,
) : AbstractRemoteProviderEnvironment() {
private var status = WorkspaceAndAgentStatus.from(workspace, agent)

private val ui: ToolboxUi = serviceLocator.getService(ToolboxUi::class.java)
override fun getId(): String = "${workspace.name}.${agent.name}"
override fun getName(): String = "${workspace.name}.${agent.name}"
private var status = WorkspaceAndAgentStatus.from(workspace, agent)

init {
actionsList.add(
Action("Open web terminal") {
// TODO - check this later
// ui.openUrl(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString())
cs.launch {
BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) {
ui.showErrorInfoPopup(it)
}
}
},
)
actionsList.add(
Action("Open in dashboard") {
// TODO - check this later
// ui.openUrl(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString())
cs.launch {
BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) {
ui.showErrorInfoPopup(it)
}
}
},
)
actionsList.add(
Action("View template") {
// TODO - check this later
// ui.openUrl(client.url.withPath("/templates/${workspace.templateName}").toString())
cs.launch {
BrowserUtil.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) {
ui.showErrorInfoPopup(it)
}
}
},
)
actionsList.add(
Expand Down Expand Up @@ -79,7 +96,7 @@ class CoderRemoteEnvironment(
val newStatus = WorkspaceAndAgentStatus.from(workspace, agent)
if (newStatus != status) {
status = newStatus
val state = status.toRemoteEnvironmentState()
val state = status.toRemoteEnvironmentState(serviceLocator)
listenerSet.forEach { it.consume(state) }
}
}
Expand Down Expand Up @@ -108,7 +125,7 @@ class CoderRemoteEnvironment(
// 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())
consumer.consume(status.toRemoteEnvironmentState(serviceLocator))
return super.addStateListener(consumer)
}

Expand Down
32 changes: 17 additions & 15 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ 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.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
Expand All @@ -39,15 +41,17 @@ import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds

class CoderRemoteProvider(
private val serviceLocator: ServiceLocator,
private val httpClient: OkHttpClient,
private val consumer: RemoteEnvironmentConsumer,
private val coroutineScope: CoroutineScope,
private val ui: ToolboxUi,
settingsStore: PluginSettingsStore,
secretsStore: PluginSecretStore,
) : RemoteProvider {
private val logger = LoggerFactory.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<CoderRemoteEnvironment>? = null
Expand All @@ -60,7 +64,7 @@ class CoderRemoteProvider(
private val dialogUi = DialogUi(settings, ui)
private val linkHandler = LinkHandler(settings, httpClient, dialogUi)

// The REST client, if we are signed in.
// The REST client, if we are signed in
private var client: CoderRestClient? = null

// If we have an error in the polling we store it here before going back to
Expand Down Expand Up @@ -96,7 +100,7 @@ class CoderRemoteProvider(
it.name
}?.map { agent ->
// If we have an environment already, update that.
val env = CoderRemoteEnvironment(client, ws, agent, ui)
val env = CoderRemoteEnvironment(serviceLocator, client, ws, agent, coroutineScope)
lastEnvironments?.firstOrNull { it == env }?.let {
it.update(ws, agent)
it
Expand Down Expand Up @@ -146,7 +150,6 @@ class CoderRemoteProvider(
// rememberMe to false so we do not try to automatically log in.
secrets.rememberMe = "false"
close()
reset()
}

/**
Expand Down Expand Up @@ -182,7 +185,7 @@ class CoderRemoteProvider(
consumer.consumeEnvironments(emptyList(), true)
}

override fun getName(): String = "Coder Gateway"
override fun getName(): String = "Coder"
override fun getSvgIcon(): SvgIcon =
SvgIcon(this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf())

Expand All @@ -208,7 +211,7 @@ 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(client?.url?.toString())
override fun getNewEnvironmentUiPage(): UiPage = NewEnvironmentPage(getDeploymentURL()?.first)

/**
* We always show a list of environments.
Expand Down Expand Up @@ -251,9 +254,8 @@ class CoderRemoteProvider(
* ui.hideUiPage() which stacks and has built-in back navigation, rather
* than using multiple root pages.
*/
private fun reset() {
// TODO - check this later
// ui.showPluginEnvironmentsPage()
private fun goToEnvironmentsPage() {
serviceLocator.getService(EnvironmentUiPageManager::class.java).showPluginEnvironmentsPage()
}

/**
Expand Down Expand Up @@ -309,7 +311,7 @@ class CoderRemoteProvider(
settings,
httpClient,
coroutineScope,
{ reset() },
::goToEnvironmentsPage,
) { client, cli ->
// Store the URL and token for use next time.
secrets.lastDeploymentURL = client.url.toString()
Expand All @@ -320,7 +322,7 @@ class CoderRemoteProvider(
pollError = null
pollJob?.cancel()
pollJob = poll(client, cli)
reset()
goToEnvironmentsPage()
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.coder.toolbox

import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import okhttp3.OkHttpClient

/**
* Entry point into the extension.
*/
class CoderToolboxExtension : RemoteDevExtension {
// All services must be passed in here and threaded as necessary.
override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider {
return CoderRemoteProvider(
serviceLocator,
OkHttpClient(),
)
}
}
5 changes: 5 additions & 0 deletions src/main/kotlin/com/coder/toolbox/browser/BrowserException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.coder.toolbox.browser

import java.io.IOException

class BrowserException(msg: String, error: Throwable? = null) : IOException(msg, error)
66 changes: 66 additions & 0 deletions src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.coder.toolbox.browser

import com.coder.toolbox.util.OS
import com.coder.toolbox.util.getOS
import org.zeroturnaround.exec.ProcessExecutor

class BrowserUtil {
companion object {
fun browse(url: String, errorHandler: (BrowserException) -> Unit) {
val os = getOS()
if (os == null) {
errorHandler(BrowserException("Failed to open the URL because we can't detect the OS"))
return
}
when (os) {
OS.LINUX -> linuxBrowse(url, errorHandler)
OS.MAC -> macBrowse(url, errorHandler)
OS.WINDOWS -> windowsBrowse(url, errorHandler)
}
}

private fun linuxBrowse(url: String, errorHandler: (BrowserException) -> Unit) {
try {
if (OS.LINUX.getDesktopEnvironment()?.uppercase()?.contains("GNOME") == true) {
exec("gnome-open", url)
} else {
exec("xdg-open", url)
}
} catch (e: Exception) {
errorHandler(
BrowserException(
"Failed to open URL because an error was encountered. Please make sure xdg-open from package xdg-utils is available!",
e
)
)
}
}

private fun macBrowse(url: String, errorHandler: (BrowserException) -> Unit) {
try {
exec("open", url)
} catch (e: Exception) {
errorHandler(BrowserException("Failed to open URL because an error was encountered.", e))
}
}

private fun windowsBrowse(url: String, errorHandler: (BrowserException) -> Unit) {
try {
exec("cmd", "start \"$url\"")
} catch (e: Exception) {
errorHandler(BrowserException("Failed to open URL because an error was encountered.", e))
}
}

private fun exec(vararg args: String): String {
val stdout =
ProcessExecutor()
.command(*args)
.exitValues(0)
.readOutput(true)
.execute()
.outputUTF8()
return stdout
}
}
}
31 changes: 16 additions & 15 deletions src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ 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.ui.color.Color
import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.core.ui.color.StateColor
import com.jetbrains.toolbox.api.core.ui.color.ThemeColor
import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState

/**
* WorkspaceAndAgentStatus represents the combined status of a single agent and
Expand Down Expand Up @@ -57,27 +58,27 @@ 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(): CustomRemoteEnvironmentState {
// Use comments; no named arguments for non-Kotlin functions.
// TODO@JB: Is there a set of default colors we could use?
fun toRemoteEnvironmentState(serviceLocator: ServiceLocator): CustomRemoteEnvironmentState {
val stateColor = getStateColor(serviceLocator)
return CustomRemoteEnvironmentState(
label,
StateColor(
ThemeColor(
Color(0.407f, 0.439f, 0.502f, 1.0f), // lightThemeColor
Color(0.784f, 0.784f, 0.784f, 0.784f), // darkThemeColor
),
ThemeColor(
Color(0.878f, 0.878f, 0.941f, 0.102f), // darkThemeBackgroundColor
Color(0.878f, 0.878f, 0.961f, 0.980f), // lightThemeBackgroundColor
)
),
stateColor,
ready(), // reachable
// TODO@JB: How does this work? Would like a spinner for pending states.
null, // iconId
)
}

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 colorPalette.getColor(StandardRemoteEnvironmentState.Unreachable)
}

/**
* Return true if the agent is in a connectable state.
*/
Expand Down
12 changes: 12 additions & 0 deletions src/main/kotlin/com/coder/toolbox/plugin/PluginInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.coder.toolbox.plugin

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* Small subset representation of extension.json
*/
@JsonClass(generateAdapter = true)
data class PluginInfo(
@Json(name = "id") val id: String,
@Json(name = "version") val version: String)
Loading