diff --git a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt index b28507f9..a30c2b76 100644 --- a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt +++ b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt @@ -23,6 +23,8 @@ private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" private const val IDE_PATH_ON_HOST = "ide_path_on_host" private const val WEB_TERMINAL_LINK = "web_terminal_link" +private const val CONFIG_DIRECTORY = "config_directory" +private const val NAME = "name" private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") @@ -33,7 +35,9 @@ fun RecentWorkspaceConnection.toWorkspaceParams(): Map { PROJECT_PATH to this.projectPath!!, IDE_PRODUCT_CODE to IntelliJPlatformProduct.fromProductCode(this.ideProductCode!!)!!.productCode, IDE_BUILD_NUMBER to "${this.ideBuildNumber}", - WEB_TERMINAL_LINK to "${this.webTerminalLink}" + WEB_TERMINAL_LINK to "${this.webTerminalLink}", + CONFIG_DIRECTORY to "${this.configDirectory}", + NAME to "${this.name}" ) if (!this.downloadSource.isNullOrBlank()) { @@ -80,6 +84,19 @@ fun Map.withWebTerminalLink(webTerminalLink: String): Map.withConfigDirectory(dir: String): Map { + val map = this.toMutableMap() + map[CONFIG_DIRECTORY] = dir + return map +} + +fun Map.withName(name: String): Map { + val map = this.toMutableMap() + map[NAME] = name + return map +} + + fun Map.areCoderType(): Boolean { return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank() } @@ -140,7 +157,9 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection this[IDE_BUILD_NUMBER]!!, this[IDE_DOWNLOAD_LINK]!!, null, - this[WEB_TERMINAL_LINK]!! + this[WEB_TERMINAL_LINK]!!, + this[CONFIG_DIRECTORY]!!, + this[NAME]!!, ) else RecentWorkspaceConnection( this.workspaceHostname(), this.projectPath(), @@ -149,6 +168,8 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection this[IDE_BUILD_NUMBER]!!, null, this[IDE_PATH_ON_HOST], - this[WEB_TERMINAL_LINK]!! + this[WEB_TERMINAL_LINK]!!, + this[CONFIG_DIRECTORY]!!, + this[NAME]!!, ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt index c2199a41..1930b0fa 100644 --- a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt +++ b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt @@ -8,6 +8,10 @@ object CoderIcons { val OPEN_TERMINAL = IconLoader.getIcon("open_terminal.svg", javaClass) + val PENDING = IconLoader.getIcon("pending.svg", javaClass) + val RUNNING = IconLoader.getIcon("running.svg", javaClass) + val OFF = IconLoader.getIcon("off.svg", javaClass) + val HOME = IconLoader.getIcon("homeFolder.svg", javaClass) val CREATE = IconLoader.getIcon("create.svg", javaClass) val RUN = IconLoader.getIcon("run.svg", javaClass) @@ -55,4 +59,4 @@ object CoderIcons { val Y = IconLoader.getIcon("y.svg", javaClass) val Z = IconLoader.getIcon("z.svg", javaClass) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt index c553cb14..b2a8f9fb 100644 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt @@ -11,4 +11,5 @@ data class CoderWorkspacesWizardModel( var token: Pair? = null, var selectedWorkspace: WorkspaceAgentModel? = null, var useExistingToken: Boolean = false, + var configDirectory: String = "", ) diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index 707216aa..4194be6c 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -3,43 +3,28 @@ package com.coder.gateway.models import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Attribute -class RecentWorkspaceConnection() : BaseState(), Comparable { - constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String) : this() { - coderWorkspaceHostname = hostname - projectPath = prjPath - lastOpened = openedAt - ideProductCode = productCode - ideBuildNumber = buildNumber - downloadSource = source - idePathOnHost = idePath - webTerminalLink = terminalLink - } - +class RecentWorkspaceConnection( @get:Attribute - var coderWorkspaceHostname by string() - + var coderWorkspaceHostname: String? = null, @get:Attribute - var projectPath by string() - + var projectPath: String? = null, @get:Attribute - var lastOpened by string() - + var lastOpened: String? = null, @get:Attribute - var ideProductCode by string() - + var ideProductCode: String? = null, @get:Attribute - var ideBuildNumber by string() - + var ideBuildNumber: String? = null, @get:Attribute - var downloadSource by string() - - + var downloadSource: String? = null, @get:Attribute - var idePathOnHost by string() - + var idePathOnHost: String? = null, @get:Attribute - var webTerminalLink by string() - + var webTerminalLink: String? = null, + @get:Attribute + var configDirectory: String? = null, + @get:Attribute + var name: String? = null, +) : BaseState(), Comparable { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -88,11 +73,11 @@ class RecentWorkspaceConnection() : BaseState(), Comparable() fun add(connection: RecentWorkspaceConnection): Boolean { - // if the item is already there but with a different last update timestamp, remove it + // If the item is already there but with a different last updated + // timestamp or config directory, remove it. recentConnections.remove(connection) - // and add it again with the new timestamp val result = recentConnections.add(connection) if (result) incrementModificationCount() return result @@ -21,4 +21,4 @@ class RecentWorkspaceConnectionState : BaseState() { if (result) incrementModificationCount() return result } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt index 2ffaa43b..1238e147 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -1,44 +1,46 @@ package com.coder.gateway.models +import com.coder.gateway.icons.CoderIcons import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.intellij.ui.JBColor +import javax.swing.Icon /** * WorkspaceAndAgentStatus represents the combined status of a single agent and * its workspace (or just the workspace if there are no agents). */ -enum class WorkspaceAndAgentStatus(val label: String, val description: String) { +enum class WorkspaceAndAgentStatus(val icon: Icon, val label: String, val description: String) { // Workspace states. - QUEUED("◍ Queued", "The workspace is queueing to start."), - STARTING("⦿ Starting", "The workspace is starting."), - FAILED("ⓧ Failed", "The workspace has failed to start."), - DELETING("⦸ Deleting", "The workspace is being deleted."), - DELETED("⦸ Deleted", "The workspace has been deleted."), - STOPPING("◍ Stopping", "The workspace is stopping."), - STOPPED("◍ Stopped", "The workspace has stopped."), - CANCELING("◍ Canceling action", "The workspace is being canceled."), - CANCELED("◍ Canceled action", "The workspace has been canceled."), - RUNNING("⦿ Running", "The workspace is running, waiting for agents."), + QUEUED(CoderIcons.PENDING, "Queued", "The workspace is queueing to start."), + STARTING(CoderIcons.PENDING, "Starting", "The workspace is starting."), + FAILED(CoderIcons.OFF, "Failed", "The workspace has failed to start."), + DELETING(CoderIcons.PENDING, "Deleting", "The workspace is being deleted."), + DELETED(CoderIcons.OFF, "Deleted", "The workspace has been deleted."), + STOPPING(CoderIcons.PENDING, "Stopping", "The workspace is stopping."), + STOPPED(CoderIcons.OFF, "Stopped", "The workspace has stopped."), + CANCELING(CoderIcons.PENDING, "Canceling action", "The workspace is being canceled."), + CANCELED(CoderIcons.OFF, "Canceled action", "The workspace has been canceled."), + RUNNING(CoderIcons.RUN, "Running", "The workspace is running, waiting for agents."), // Agent states. - CONNECTING("⦿ Connecting", "The agent is connecting."), - DISCONNECTED("⦸ Disconnected", "The agent has disconnected."), - TIMEOUT("ⓧ Timeout", "The agent is taking longer than expected to connect."), - AGENT_STARTING("⦿ Starting", "The startup script is running."), - AGENT_STARTING_READY("⦿ Starting", "The startup script is still running but the agent is ready to accept connections."), - CREATED("⦿ Created", "The agent has been created."), - START_ERROR("◍ Started with error", "The agent is ready but the startup script errored."), - START_TIMEOUT("◍ Starting", "The startup script is taking longer than expected."), - START_TIMEOUT_READY("◍ Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."), - SHUTTING_DOWN("◍ Shutting down", "The agent is shutting down."), - SHUTDOWN_ERROR("⦸ Shutdown with error", "The agent shut down but the shutdown script errored."), - SHUTDOWN_TIMEOUT("⦸ Shutting down", "The shutdown script is taking longer than expected."), - OFF("⦸ Off", "The agent has shut down."), - READY("⦿ Ready", "The agent is ready to accept connections."); + CONNECTING(CoderIcons.PENDING, "Connecting", "The agent is connecting."), + DISCONNECTED(CoderIcons.OFF, "Disconnected", "The agent has disconnected."), + TIMEOUT(CoderIcons.PENDING, "Timeout", "The agent is taking longer than expected to connect."), + AGENT_STARTING(CoderIcons.PENDING, "Starting", "The startup script is running."), + AGENT_STARTING_READY(CoderIcons.RUNNING, "Starting", "The startup script is still running but the agent is ready to accept connections."), + CREATED(CoderIcons.PENDING, "Created", "The agent has been created."), + START_ERROR(CoderIcons.RUNNING, "Started with error", "The agent is ready but the startup script errored."), + START_TIMEOUT(CoderIcons.PENDING, "Starting", "The startup script is taking longer than expected."), + START_TIMEOUT_READY(CoderIcons.RUNNING, "Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."), + SHUTTING_DOWN(CoderIcons.PENDING, "Shutting down", "The agent is shutting down."), + SHUTDOWN_ERROR(CoderIcons.OFF, "Shutdown with error", "The agent shut down but the shutdown script errored."), + SHUTDOWN_TIMEOUT(CoderIcons.OFF, "Shutting down", "The shutdown script is taking longer than expected."), + OFF(CoderIcons.OFF, "Off", "The agent has shut down."), + READY(CoderIcons.RUNNING, "Ready", "The agent is ready to accept connections."); fun statusColor(): JBColor = when (this) { READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN @@ -100,7 +102,5 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { WorkspaceStatus.DELETING -> DELETING WorkspaceStatus.DELETED -> DELETED } - - fun from(str: String) = WorkspaceAndAgentStatus.values().first { it.label.contains(str, true) } } } diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index cd0c9802..4ea27219 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -31,7 +31,7 @@ class CoderCLIManager @JvmOverloads constructor( ) { var remoteBinaryURL: URL var localBinaryPath: Path - private var coderConfigPath: Path + var coderConfigPath: Path init { val binaryName = getCoderCLIForOS(getOS(), getArch()) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt index 0b99f2ae..244ebba8 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt @@ -31,18 +31,30 @@ import java.util.UUID class CoderRestClientService { var isReady: Boolean = false private set - private lateinit var httpClient: OkHttpClient - private lateinit var retroRestClient: CoderV2RestFacade - private lateinit var sessionToken: String - lateinit var coderURL: URL lateinit var me: User lateinit var buildVersion: String + lateinit var client: CoderRestClient /** - * This must be called before anything else. It will authenticate with coder and retrieve a session token + * This must be called before anything else. It will authenticate and load + * information about the current user and the build version. + * * @throws [AuthenticationResponseException] if authentication failed. */ fun initClientSession(url: URL, token: String): User { + client = CoderRestClient(url, token) + me = client.me() + buildVersion = client.buildInfo().version + isReady = true + return me + } +} + +class CoderRestClient(var url: URL, private var token: String) { + private var httpClient: OkHttpClient + private var retroRestClient: CoderV2RestFacade + + init { val gson: Gson = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()).setPrettyPrinting().create() val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("com.coder.gateway"))!! // this is the id from the plugin.xml @@ -54,18 +66,19 @@ class CoderRestClientService { .build() retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient).addConverterFactory(GsonConverterFactory.create(gson)).build().create(CoderV2RestFacade::class.java) + } + /** + * Retrieve the current user. + * @throws [AuthenticationResponseException] if authentication failed. + */ + fun me(): User { val userResponse = retroRestClient.me().execute() if (!userResponse.isSuccessful) { - throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message()}") + throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message().ifBlank { "no reason provided" }}") } - coderURL = url - sessionToken = token - me = userResponse.body()!! - buildVersion = buildInfo().version - isReady = true - return me + return userResponse.body()!! } /** @@ -75,24 +88,24 @@ class CoderRestClientService { fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me").execute() if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message()}") + throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") } return workspacesResponse.body()!!.workspaces } - private fun buildInfo(): BuildInfo { + fun buildInfo(): BuildInfo { val buildInfoResponse = retroRestClient.buildInfo().execute() if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $coderURL, reason:${buildInfoResponse.message()}") + throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $url, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}") } return buildInfoResponse.body()!! } - private fun template(templateID: UUID): Template { + fun template(templateID: UUID): Template { val templateResponse = retroRestClient.template(templateID).execute() if (!templateResponse.isSuccessful) { - throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message()}") + throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") } return templateResponse.body()!! } @@ -101,7 +114,7 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -111,7 +124,7 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -123,9 +136,9 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt index a8575de8..fad38062 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt @@ -18,7 +18,7 @@ import javax.swing.Icon @Service(Service.Level.APP) class TemplateIconDownloader { - private val coderClient: CoderRestClientService = service() + private val clientService: CoderRestClientService = service() private val cache = mutableMapOf, Icon>() fun load(path: String, workspaceName: String): Icon { @@ -26,7 +26,7 @@ class TemplateIconDownloader { if (path.startsWith("http")) { url = path.toURL() } else if (!path.contains(":") && !path.contains("//")) { - url = coderClient.coderURL.withPath(path) + url = clientService.client.url.withPath(path) } if (url != null) { @@ -115,4 +115,4 @@ class TemplateIconDownloader { else -> CoderIcons.UNKNOWN } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt index 8ccecaa2..129489c6 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt @@ -1,5 +1,10 @@ package com.coder.gateway.sdk.v2.models +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.models.WorkspaceAndAgentStatus +import com.coder.gateway.models.WorkspaceVersionStatus +import com.coder.gateway.sdk.Arch +import com.coder.gateway.sdk.OS import com.google.gson.annotations.SerializedName import java.time.Instant import java.util.UUID @@ -24,4 +29,48 @@ data class Workspace( @SerializedName("autostart_schedule") val autostartSchedule: String?, @SerializedName("ttl_ms") val ttlMillis: Long?, @SerializedName("last_used_at") val lastUsedAt: Instant, -) \ No newline at end of file +) + +fun Workspace.toAgentModels(): Set { + val wam = this.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> + val workspaceWithAgentName = "${this.name}.${agent.name}" + val wm = WorkspaceAgentModel( + this.id, + this.name, + workspaceWithAgentName, + this.templateID, + this.templateName, + this.templateIcon, + null, + WorkspaceVersionStatus.from(this), + this.latestBuild.status, + WorkspaceAndAgentStatus.from(this, agent), + this.latestBuild.transition, + OS.from(agent.operatingSystem), + Arch.from(agent.architecture), + agent.expandedDirectory ?: agent.directory, + ) + + wm + }.toSet() + if (wam.isNullOrEmpty()) { + val wm = WorkspaceAgentModel( + this.id, + this.name, + this.name, + this.templateID, + this.templateName, + this.templateIcon, + null, + WorkspaceVersionStatus.from(this), + this.latestBuild.status, + WorkspaceAndAgentStatus.from(this), + this.latestBuild.transition, + null, + null, + null + ) + return setOf(wm) + } + return wam +} diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index 130d2ea5..e38bef7b 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -6,6 +6,11 @@ import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.RecentWorkspaceConnection +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.sdk.toURL +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.sdk.v2.models.toAgentModels import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService import com.coder.gateway.toWorkspaceParams import com.intellij.icons.AllIcons @@ -13,9 +18,11 @@ import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.ui.panel.ComponentPanelBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.ui.AnimatedIcon import com.intellij.ui.DocumentAdapter import com.intellij.ui.SearchTextField import com.intellij.ui.components.ActionLink @@ -26,23 +33,45 @@ import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.TopGap import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.util.maximumWidth +import com.intellij.ui.util.minimumWidth +import com.intellij.util.io.readText import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.jetbrains.gateway.api.GatewayRecentConnections import com.jetbrains.gateway.api.GatewayUI import com.jetbrains.gateway.ssh.IntelliJPlatformProduct import com.jetbrains.rd.util.lifetime.Lifetime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.Component import java.awt.Dimension -import java.util.* +import java.nio.file.Path +import java.util.Locale import javax.swing.JComponent import javax.swing.JLabel import javax.swing.event.DocumentEvent +/** + * DeploymentInfo contains everything needed to query the API for a deployment + * along with the latest workspace responses. + */ +data class DeploymentInfo( + // Null if unable to create the client (config directory did not exist). + var client: CoderRestClient? = null, + // Null if we have not fetched workspaces yet. + var workspaces: List? = null, + // Null if there have not been any errors yet. + var error: String? = null, +) + class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : GatewayRecentConnections, Disposable { private val recentConnectionsService = service() private val cs = CoroutineScope(Dispatchers.Main) @@ -50,16 +79,24 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: private val recentWorkspacesContentPanel = JBScrollPane() private lateinit var searchBar: SearchTextField + private var filterString: String? = null override val id = CoderGatewayConstants.GATEWAY_RECENT_CONNECTIONS_ID override val recentsIcon = CoderIcons.LOGO_16 + /** + * API clients and workspaces grouped by deployment and keyed by their + * config directory. + */ + private var deployments: Map = emptyMap() + private var poller: Job? = null + override fun createRecentsView(lifetime: Lifetime): JComponent { return panel { indent { row { - label(CoderGatewayBundle.message("gateway.connector.recentconnections.title")).applyToComponent { + label(CoderGatewayBundle.message("gateway.connector.recent-connections.title")).applyToComponent { font = JBFont.h3().asBold() } panel { @@ -71,16 +108,14 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: textEditor.border = JBUI.Borders.empty(2, 5, 2, 0) addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { - val toSearchFor = this@applyToComponent.text - val filteredConnections = recentConnectionsService.getAllRecentConnections() - .filter { it.coderWorkspaceHostname?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false || it.projectPath?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false } - updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname }) + filterString = this@applyToComponent.text.trim() + updateContentView() } }) }.component actionButton( - object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.new.wizard.button.tooltip"), null, AllIcons.General.Add) { + object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.new.wizard.button.tooltip"), null, AllIcons.General.Add) { override fun actionPerformed(e: AnActionEvent) { setContentCallback(CoderGatewayConnectorWizardWrapperView().component) } @@ -105,26 +140,79 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title") override fun updateRecentView() { - updateContentView(recentConnectionsService.getAllRecentConnections().groupBy { it.coderWorkspaceHostname }) + triggerWorkspacePolling() + updateContentView() } - private fun updateContentView(groupedConnections: Map>) { + private fun updateContentView() { + val connections = recentConnectionsService.getAllRecentConnections() + .filter { it.coderWorkspaceHostname != null } + .filter { matchesFilter(it) } + .groupBy { it.coderWorkspaceHostname!! } recentWorkspacesContentPanel.viewport.view = panel { - groupedConnections.entries.forEach { (hostname, recentConnections) -> + connections.forEach { (hostname, connections) -> + // The config directory and name will not exist on connections + // made with 2.3.0 and earlier. + val name = connections.firstNotNullOfOrNull { it.name } + val workspaceName = name?.split(".", limit = 2)?.first() + val configDirectory = connections.firstNotNullOfOrNull { it.configDirectory } + val deployment = deployments[configDirectory] + val workspace = deployment?.workspaces + ?.firstOrNull { it.name == name || it.workspaceName == workspaceName } row { - if (hostname != null) { - label(hostname).applyToComponent { - font = JBFont.h3().asBold() - }.align(AlignX.LEFT).gap(RightGap.SMALL) - actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { - override fun actionPerformed(e: AnActionEvent) { - BrowserUtil.browse(recentConnections[0].webTerminalLink ?: "") - } - }) + (if (workspace != null) { + icon(workspace.agentStatus.icon).applyToComponent { + foreground = workspace.agentStatus.statusColor() + toolTipText = workspace.agentStatus.description + } + } else if (configDirectory == null || workspaceName == null) { + icon(CoderIcons.UNKNOWN).applyToComponent { + toolTipText = "Unable to determine workspace status because the configuration directory and/or name were not recorded. To fix, add the connection again." + } + } else if (deployment?.error != null) { + icon(UIUtil.getBalloonErrorIcon()).applyToComponent { + toolTipText = deployment.error + } + } else if (deployment?.workspaces != null) { + icon(UIUtil.getBalloonErrorIcon()).applyToComponent { + toolTipText = "Workspace $workspaceName does not exist" + } + } else { + icon(AnimatedIcon.Default.INSTANCE).applyToComponent { + toolTipText = "Querying workspace status..." + } + }).align(AlignX.LEFT).gap(RightGap.SMALL).applyToComponent { + maximumWidth = JBUI.scale(16) + minimumWidth = JBUI.scale(16) } + label(hostname.removePrefix("coder-jetbrains--")).applyToComponent { + font = JBFont.h3().asBold() + }.align(AlignX.LEFT).gap(RightGap.SMALL) + label("").resizableColumn().align(AlignX.FILL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.start.button.tooltip"), "", CoderIcons.RUN) { + override fun actionPerformed(e: AnActionEvent) { + if (workspace != null) { + deployment.client?.startWorkspace(workspace.workspaceID, workspace.workspaceName) + cs.launch { fetchWorkspaces() } + } + } + }).applyToComponent { isEnabled = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.FAILED).contains(workspace?.workspaceStatus) }.gap(RightGap.SMALL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.stop.button.tooltip"), "", CoderIcons.STOP) { + override fun actionPerformed(e: AnActionEvent) { + if (workspace != null) { + deployment.client?.stopWorkspace(workspace.workspaceID, workspace.workspaceName) + cs.launch { fetchWorkspaces() } + } + } + }).applyToComponent { isEnabled = workspace?.workspaceStatus == WorkspaceStatus.RUNNING }.gap(RightGap.SMALL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(connections[0].webTerminalLink ?: "") + } + }) }.topGap(TopGap.MEDIUM) - recentConnections.forEach { connectionDetails -> + connections.forEach { connectionDetails -> val product = IntelliJPlatformProduct.fromProductCode(connectionDetails.ideProductCode!!)!! row { icon(product.icon) @@ -138,10 +226,10 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND font = ComponentPanelBuilder.getCommentFont(font) } - actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.remove.button.tooltip"), "", CoderIcons.DELETE) { + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.remove.button.tooltip"), "", CoderIcons.DELETE) { override fun actionPerformed(e: AnActionEvent) { recentConnectionsService.removeConnection(connectionDetails) - updateContentView(recentConnectionsService.getAllRecentConnections().groupBy { it.coderWorkspaceHostname }) + updateRecentView() } }) } @@ -149,11 +237,88 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: } }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(12, 0, 0, 12) + border = JBUI.Borders.empty(12, 0, 12, 12) } } + /** + * Return true if the connection matches the current filter. + */ + private fun matchesFilter(connection: RecentWorkspaceConnection): Boolean { + return filterString.isNullOrBlank() + || connection.coderWorkspaceHostname?.lowercase(Locale.getDefault())?.contains(filterString!!) == true + || connection.projectPath?.lowercase(Locale.getDefault())?.contains(filterString!!) == true + } + + /** + * Start polling for workspaces if not already started. + */ + private fun triggerWorkspacePolling() { + deployments = recentConnectionsService.getAllRecentConnections() + .mapNotNull { it.configDirectory }.toSet() + .associateWith { dir -> + deployments[dir] ?: try { + val url = Path.of(dir).resolve("url").readText() + val token = Path.of(dir).resolve("session").readText() + DeploymentInfo(CoderRestClient(url.toURL(), token)) + } catch (e: Exception) { + logger.error("Unable to create client from $dir", e) + DeploymentInfo(error = "Error trying to read $dir: ${e.message}") + } + } + + if (poller?.isActive == true) { + logger.info("Refusing to start already-started poller") + return + } + + logger.info("Starting poll loop") + poller = cs.launch { + while (isActive) { + if (recentWorkspacesContentPanel.isShowing) { + fetchWorkspaces() + } else { + logger.info("View not visible; aborting poll") + poller?.cancel() + } + delay(5000) + } + } + } + + /** + * Update each deployment with their latest workspaces. + */ + private suspend fun fetchWorkspaces() { + withContext(Dispatchers.IO) { + deployments.values + .filter { it.error == null && it.client != null} + .forEach { deployment -> + val url = deployment.client!!.url + try { + deployment.workspaces = deployment.client!! + .workspaces().flatMap { it.toAgentModels() } + } catch (e: Exception) { + logger.error("Failed to fetch workspaces from $url", e) + deployment.error = e.message ?: "Request failed without further details" + } + } + } + withContext(Dispatchers.Main) { + updateContentView() + } + } + + // Note that this is *not* called when you navigate away from the page so + // check for visibility if you want to avoid work while the panel is not + // displaying. override fun dispose() { + logger.info("Disposing recent view") cs.cancel() + poller?.cancel() + } + + companion object { + val logger = Logger.getInstance(CoderGatewayRecentWorkspaceConnectionsView::class.java.simpleName) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 3b209edd..8f5be284 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -16,6 +16,8 @@ import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.withPath import com.coder.gateway.toWorkspaceParams import com.coder.gateway.views.LazyBrowserLink +import com.coder.gateway.withConfigDirectory +import com.coder.gateway.withName import com.coder.gateway.withProjectPath import com.coder.gateway.withWebTerminalLink import com.coder.gateway.withWorkspaceHostname @@ -84,7 +86,7 @@ import javax.swing.event.DocumentEvent class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable { private val cs = CoroutineScope(Dispatchers.Main) - private val coderClient: CoderRestClientService = ApplicationManager.getApplication().getService(CoderRestClientService::class.java) + private val clientService: CoderRestClientService = ApplicationManager.getApplication().getService(CoderRestClientService::class.java) private var ideComboBoxModel = DefaultComboBoxModel() @@ -177,13 +179,13 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea tfProject.text = if (selectedWorkspace.homeDirectory.isNullOrBlank()) "/home" else selectedWorkspace.homeDirectory titleLabel.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", selectedWorkspace.name) - terminalLink.url = coderClient.coderURL.withPath("/@${coderClient.me.username}/${selectedWorkspace.name}/terminal").toString() + terminalLink.url = clientService.client.url.withPath("/@${clientService.me.username}/${selectedWorkspace.name}/terminal").toString() ideResolvingJob = cs.launch { try { val ides = suspendingRetryWithExponentialBackOff( action = { attempt -> - logger.info("Retrieving IDEs...(attempt $attempt)") + logger.info("Retrieving IDEs... (attempt $attempt)") if (attempt > 1) { cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve.ides.retry", attempt)) } @@ -334,6 +336,8 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) .withProjectPath(tfProject.text) .withWebTerminalLink("${terminalLink.url}") + .withConfigDirectory(wizardModel.configDirectory) + .withName(selectedWorkspace.name) ) } return true diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 62d4c1d3..066b71eb 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -5,9 +5,7 @@ import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel import com.coder.gateway.models.TokenSource import com.coder.gateway.models.WorkspaceAgentModel -import com.coder.gateway.models.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus -import com.coder.gateway.sdk.Arch import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClientService import com.coder.gateway.sdk.CoderSemVer @@ -20,8 +18,8 @@ import com.coder.gateway.sdk.ex.AuthenticationResponseException import com.coder.gateway.sdk.ex.TemplateResponseException import com.coder.gateway.sdk.ex.WorkspaceResponseException import com.coder.gateway.sdk.toURL -import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.sdk.v2.models.toAgentModels import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderSettingsState import com.intellij.ide.ActivityTracker @@ -100,7 +98,7 @@ private const val MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW = "MOUSE_OVER_TEMPLATE_ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable { private val cs = CoroutineScope(Dispatchers.Main) private var localWizardModel = CoderWorkspacesWizardModel() - private val coderClient: CoderRestClientService = service() + private val clientService: CoderRestClientService = service() private val iconDownloader: TemplateIconDownloader = service() private val settings: CoderSettingsState = service() @@ -145,7 +143,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val workspace = tblView.selectedObject if (col == 2 && workspace != null) { - BrowserUtil.browse(coderClient.coderURL.toURI().resolve("/templates/${workspace.templateName}")) + BrowserUtil.browse(clientService.client.url.toURI().resolve("/templates/${workspace.templateName}")) } } } @@ -270,7 +268,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private inner class GoToDashboardAction : AnActionButton(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.text"), CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.text"), CoderIcons.HOME) { override fun actionPerformed(p0: AnActionEvent) { - BrowserUtil.browse(coderClient.coderURL) + BrowserUtil.browse(clientService.client.url) } } @@ -282,7 +280,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.startWorkspace(workspace.workspaceID, workspace.workspaceName) + clientService.client.startWorkspace(workspace.workspaceID, workspace.workspaceName) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not build workspace ${workspace.name}, reason: $e") @@ -301,7 +299,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.updateWorkspace(workspace.workspaceID, workspace.workspaceName, workspace.lastBuildTransition, workspace.templateID) + clientService.client.updateWorkspace(workspace.workspaceID, workspace.workspaceName, workspace.lastBuildTransition, workspace.templateID) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not update workspace ${workspace.name}, reason: $e") @@ -322,7 +320,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.stopWorkspace(workspace.workspaceID, workspace.workspaceName) + clientService.client.stopWorkspace(workspace.workspaceID, workspace.workspaceName) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not stop workspace ${workspace.name}, reason: $e") @@ -336,7 +334,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private inner class CreateWorkspaceAction : AnActionButton(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.text"), CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.text"), CoderIcons.CREATE) { override fun actionPerformed(p0: AnActionEvent) { - BrowserUtil.browse(coderClient.coderURL.toURI().resolve("/templates")) + BrowserUtil.browse(clientService.client.url.toURI().resolve("/templates")) } } @@ -373,8 +371,8 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } private fun updateWorkspaceActions() { - goToDashboardAction.isEnabled = coderClient.isReady - createWorkspaceAction.isEnabled = coderClient.isReady + goToDashboardAction.isEnabled = clientService.isReady + createWorkspaceAction.isEnabled = clientService.isReady when (tableOfWorkspaces.selectedObject?.workspaceStatus) { WorkspaceStatus.RUNNING -> { startWorkspaceAction.isEnabled = false @@ -623,12 +621,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod */ private fun authenticate(url: URL, token: String) { logger.info("Authenticating to $url...") - coderClient.initClientSession(url, token) + clientService.initClientSession(url, token) try { - logger.info("Checking compatibility with Coder version ${coderClient.buildVersion}...") - CoderSemVer.checkVersionCompatibility(coderClient.buildVersion) - logger.info("${coderClient.buildVersion} is compatible") + logger.info("Checking compatibility with Coder version ${clientService.buildVersion}...") + CoderSemVer.checkVersionCompatibility(clientService.buildVersion) + logger.info("${clientService.buildVersion} is compatible") } catch (e: InvalidVersionException) { logger.warn(e) notificationBanner.apply { @@ -636,7 +634,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod showWarning( CoderGatewayBundle.message( "gateway.connector.view.coder.workspaces.invalid.coder.version", - coderClient.buildVersion + clientService.buildVersion ) ) } @@ -644,7 +642,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod logger.warn(e) notificationBanner.apply { component.isVisible = true - showWarning(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.coder.version", coderClient.buildVersion)) + showWarning(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.coder.version", clientService.buildVersion)) } } @@ -658,13 +656,21 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val ws = withContext(Dispatchers.IO) { val timeBeforeRequestingWorkspaces = System.currentTimeMillis() try { - val ws = coderClient.workspaces() - val ams = ws.flatMap { it.toAgentModels() }.toSet() + val ws = clientService.client.workspaces() + val ams = ws.flatMap { it.toAgentModels() } + ams.forEach { + cs.launch(Dispatchers.IO) { + it.templateIcon = iconDownloader.load(it.templateIconPath, it.name) + withContext(Dispatchers.Main) { + tableOfWorkspaces.updateUI() + } + } + } val timeAfterRequestingWorkspaces = System.currentTimeMillis() logger.info("Retrieving the workspaces took: ${timeAfterRequestingWorkspaces - timeBeforeRequestingWorkspaces} millis") return@withContext ams } catch (e: Exception) { - logger.error("Could not retrieve workspaces for ${coderClient.me.username} on ${coderClient.coderURL}. Reason: $e") + logger.error("Could not retrieve workspaces for ${clientService.me.username} on ${clientService.client.url}. Reason: $e") emptySet() } } @@ -675,62 +681,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private fun Workspace.toAgentModels(): Set { - val wam = this.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> - val workspaceWithAgentName = "${this.name}.${agent.name}" - val wm = WorkspaceAgentModel( - this.id, - this.name, - workspaceWithAgentName, - this.templateID, - this.templateName, - this.templateIcon, - null, - WorkspaceVersionStatus.from(this), - this.latestBuild.status, - WorkspaceAndAgentStatus.from(this, agent), - this.latestBuild.transition, - OS.from(agent.operatingSystem), - Arch.from(agent.architecture), - agent.expandedDirectory ?: agent.directory, - ) - cs.launch(Dispatchers.IO) { - wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } - } - wm - }.toSet() - - if (wam.isNullOrEmpty()) { - val wm = WorkspaceAgentModel( - this.id, - this.name, - this.name, - this.templateID, - this.templateName, - this.templateIcon, - null, - WorkspaceVersionStatus.from(this), - this.latestBuild.status, - WorkspaceAndAgentStatus.from(this), - this.latestBuild.transition, - null, - null, - null - ) - cs.launch(Dispatchers.IO) { - wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } - } - return setOf(wm) - } - return wam - } - override fun onPrevious() { super.onPrevious() logger.info("Going back to the main view") @@ -757,6 +707,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ) cliManager.configSsh(tableOfWorkspaces.items) + // The config directory can be used to pull the URL and token in + // order to query this workspace's status in other flows, for + // example from the recent connections screen. + wizardModel.configDirectory = cliManager.coderConfigPath.toString() + logger.info("Opening IDE and Project Location window for ${workspace.name}") return true } @@ -904,12 +859,13 @@ class WorkspacesTableModel : ListTableModel( override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { return object : DefaultTableCellRenderer() { + private val workspace = item override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) if (value is String) { text = value - foreground = WorkspaceAndAgentStatus.from(value).statusColor() - toolTipText = WorkspaceAndAgentStatus.from(value).description + foreground = workspace?.agentStatus?.statusColor() + toolTipText = workspace?.agentStatus?.description } font = table.tableHeader.font border = JBUI.Borders.empty(0, 8) diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index c5e7e8b0..fc2b3750 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -11,11 +11,11 @@ gateway.connector.view.coder.workspaces.header.text=Coder Workspaces gateway.connector.view.coder.workspaces.comment=Self-hosted developer workspaces in the cloud or on-premises. Coder empowers developers with secure, consistent, and fast developer workspaces. gateway.connector.view.coder.workspaces.connect.text=Connect gateway.connector.view.coder.workspaces.cli.downloader.dialog.title=Authenticate and setup Coder -gateway.connector.view.coder.workspaces.next.text=Select IDE and Project -gateway.connector.view.coder.workspaces.dashboard.text=Open Dashboard -gateway.connector.view.coder.workspaces.start.text=Start Workspace -gateway.connector.view.coder.workspaces.stop.text=Stop Workspace -gateway.connector.view.coder.workspaces.update.text=Update Workspace Template +gateway.connector.view.coder.workspaces.next.text=Select IDE and project +gateway.connector.view.coder.workspaces.dashboard.text=Open dashboard +gateway.connector.view.coder.workspaces.start.text=Start workspace +gateway.connector.view.coder.workspaces.stop.text=Stop workspace +gateway.connector.view.coder.workspaces.update.text=Update workspace template gateway.connector.view.coder.workspaces.create.text=Create workspace gateway.connector.view.coder.workspaces.unsupported.os.info=Gateway supports only Linux machines. Support for macOS and Windows is planned. gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. Connect to a Coder workspace manually @@ -37,10 +37,12 @@ gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project fo gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded from jetbrains.com and installed to the default path on the remote host. gateway.connector.view.coder.remoteproject.ide.installed.comment=This IDE is already installed and will be used as-is. gateway.connector.view.coder.remoteproject.ide.none.comment=No IDE selected. -gateway.connector.recentconnections.title=Recent Coder Workspaces -gateway.connector.recentconnections.new.wizard.button.tooltip=Open a new Coder Workspace -gateway.connector.recentconnections.remove.button.tooltip=Remove from Recent Connections -gateway.connector.recentconnections.terminal.button.tooltip=Open SSH Web Terminal +gateway.connector.recent-connections.title=Recent Coder workspaces +gateway.connector.recent-connections.new.wizard.button.tooltip=Open a new Coder workspace +gateway.connector.recent-connections.remove.button.tooltip=Remove from recent connections +gateway.connector.recent-connections.terminal.button.tooltip=Open SSH web terminal +gateway.connector.recent-connections.start.button.tooltip=Start workspace +gateway.connector.recent-connections.stop.button.tooltip=Stop workspace gateway.connector.coder.connection.provider.title=Connecting to Coder workspace... gateway.connector.coder.connecting=Connecting... gateway.connector.coder.connecting.retry=Connecting (attempt {0})... diff --git a/src/main/resources/off.svg b/src/main/resources/off.svg new file mode 100644 index 00000000..fed5a568 --- /dev/null +++ b/src/main/resources/off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/pending.svg b/src/main/resources/pending.svg new file mode 100644 index 00000000..2c98bace --- /dev/null +++ b/src/main/resources/pending.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/running.svg b/src/main/resources/running.svg new file mode 100644 index 00000000..ff92e3f1 --- /dev/null +++ b/src/main/resources/running.svg @@ -0,0 +1,6 @@ + + + + + +