diff --git a/CHANGELOG.md b/CHANGELOG.md index 3542a403..a70d5a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - ability to open a template in the Dashboard - ability to sort by workspace name, or by template name or by workspace status - a new token is requested when the one persisted is expired +- support for re-using already installed IDE backends ### Changed - renamed the plugin from `Coder Gateway` to `Gateway` diff --git a/gradle.properties b/gradle.properties index cd3b6ad8..4329fb70 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ pluginGroup=com.coder.gateway pluginName=coder-gateway # SemVer format -> https://semver.org -pluginVersion=2.1.3 +pluginVersion=2.1.4 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=222.3739.54 diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index 0b9dcd66..9ca0d804 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -2,93 +2,39 @@ package com.coder.gateway -import com.coder.gateway.models.RecentWorkspaceConnection import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService import com.intellij.openapi.components.service import com.intellij.openapi.rd.util.launchUnderBackgroundProgress -import com.intellij.remote.AuthType -import com.intellij.remote.RemoteCredentialsHolder -import com.intellij.ssh.config.unified.SshConfig import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider import com.jetbrains.gateway.api.GatewayUI -import com.jetbrains.gateway.ssh.HighLevelHostAccessor -import com.jetbrains.gateway.ssh.HostDeployInputs -import com.jetbrains.gateway.ssh.IdeInfo -import com.jetbrains.gateway.ssh.IntelliJPlatformProduct import com.jetbrains.gateway.ssh.SshDeployFlowUtil import com.jetbrains.gateway.ssh.SshMultistagePanelContext -import com.jetbrains.gateway.ssh.deploy.DeployTargetInfo.DeployWithDownload import com.jetbrains.rd.util.lifetime.LifetimeDefinition import kotlinx.coroutines.launch -import java.net.URI import java.time.Duration -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter class CoderGatewayConnectionProvider : GatewayConnectionProvider { private val recentConnectionsService = service() - private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") - override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { - val coderWorkspaceHostname = parameters["coder_workspace_hostname"] - val projectPath = parameters["project_path"] - val ideProductCode = parameters["ide_product_code"]!! - val ideBuildNumber = parameters["ide_build_number"]!! - val ideDownloadLink = parameters["ide_download_link"]!! - val webTerminalLink = parameters["web_terminal_link"]!! - - if (coderWorkspaceHostname != null && projectPath != null) { - val sshConfiguration = SshConfig(true).apply { - setHost(coderWorkspaceHostname) - setUsername("coder") - port = 22 - authType = AuthType.OPEN_SSH - } - - val clientLifetime = LifetimeDefinition() - clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { - val context = SshMultistagePanelContext( - HostDeployInputs.FullySpecified( - remoteProjectPath = projectPath, - deployTarget = DeployWithDownload( - URI(ideDownloadLink), - null, - IdeInfo( - product = IntelliJPlatformProduct.fromProductCode(ideProductCode)!!, - buildNumber = ideBuildNumber - ) - ), - remoteInfo = HostDeployInputs.WithDeployedWorker( - HighLevelHostAccessor.create( - RemoteCredentialsHolder().apply { - setHost(coderWorkspaceHostname) - userName = "coder" - port = 22 - authType = AuthType.OPEN_SSH - }, - true - ), - HostDeployInputs.WithHostInfo(sshConfiguration) - ) - ) + val clientLifetime = LifetimeDefinition() + clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { + val context = SshMultistagePanelContext(parameters.toHostDeployInputs()) + launch { + @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( + clientLifetime, context, Duration.ofMinutes(10) ) - launch { - @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( - clientLifetime, context, Duration.ofMinutes(10) - ) - } } - - recentConnectionsService.addRecentConnection(RecentWorkspaceConnection(coderWorkspaceHostname, projectPath, localTimeFormatter.format(LocalDateTime.now()), ideProductCode, ideBuildNumber, ideDownloadLink, webTerminalLink)) - GatewayUI.getInstance().reset() } + + recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) + GatewayUI.getInstance().reset() return null } override fun isApplicable(parameters: Map): Boolean { - return parameters["type"] == "coder" + return parameters.areCoderType() } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt new file mode 100644 index 00000000..b28507f9 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt @@ -0,0 +1,154 @@ +package com.coder.gateway + +import com.coder.gateway.models.RecentWorkspaceConnection +import com.intellij.remote.AuthType +import com.intellij.remote.RemoteCredentialsHolder +import com.intellij.ssh.config.unified.SshConfig +import com.jetbrains.gateway.ssh.HighLevelHostAccessor +import com.jetbrains.gateway.ssh.HostDeployInputs +import com.jetbrains.gateway.ssh.IdeInfo +import com.jetbrains.gateway.ssh.IdeWithStatus +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.deploy.DeployTargetInfo +import java.net.URI +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +private const val CODER_WORKSPACE_HOSTNAME = "coder_workspace_hostname" +private const val TYPE = "type" +private const val VALUE_FOR_TYPE = "coder" +private const val PROJECT_PATH = "project_path" +private const val IDE_DOWNLOAD_LINK = "ide_download_link" +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 val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") + +fun RecentWorkspaceConnection.toWorkspaceParams(): Map { + val map = mutableMapOf( + TYPE to VALUE_FOR_TYPE, + CODER_WORKSPACE_HOSTNAME to "${this.coderWorkspaceHostname}", + 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}" + ) + + if (!this.downloadSource.isNullOrBlank()) { + map[IDE_DOWNLOAD_LINK] = this.downloadSource!! + } else { + map[IDE_PATH_ON_HOST] = this.idePathOnHost!! + } + return map +} + +fun IdeWithStatus.toWorkspaceParams(): Map { + val workspaceParams = mutableMapOf( + TYPE to VALUE_FOR_TYPE, + IDE_PRODUCT_CODE to this.product.productCode, + IDE_BUILD_NUMBER to this.buildNumber + ) + + if (this.download != null) { + workspaceParams[IDE_DOWNLOAD_LINK] = this.download!!.link + } + + if (!this.pathOnHost.isNullOrBlank()) { + workspaceParams[IDE_PATH_ON_HOST] = this.pathOnHost!! + } + + return workspaceParams +} + +fun Map.withWorkspaceHostname(hostname: String): Map { + val map = this.toMutableMap() + map[CODER_WORKSPACE_HOSTNAME] = hostname + return map +} + +fun Map.withProjectPath(projectPath: String): Map { + val map = this.toMutableMap() + map[PROJECT_PATH] = projectPath + return map +} + +fun Map.withWebTerminalLink(webTerminalLink: String): Map { + val map = this.toMutableMap() + map[WEB_TERMINAL_LINK] = webTerminalLink + return map +} + +fun Map.areCoderType(): Boolean { + return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank() +} + +fun Map.toSshConfig(): SshConfig { + return SshConfig(true).apply { + setHost(this@toSshConfig.workspaceHostname()) + setUsername("coder") + port = 22 + authType = AuthType.OPEN_SSH + } +} + +suspend fun Map.toHostDeployInputs(): HostDeployInputs { + return HostDeployInputs.FullySpecified( + remoteProjectPath = this[PROJECT_PATH]!!, + deployTarget = this.toDeployTargetInfo(), + remoteInfo = HostDeployInputs.WithDeployedWorker( + HighLevelHostAccessor.create( + RemoteCredentialsHolder().apply { + setHost(this@toHostDeployInputs.workspaceHostname()) + userName = "coder" + port = 22 + authType = AuthType.OPEN_SSH + }, + true + ), + HostDeployInputs.WithHostInfo(this.toSshConfig()) + ) + ) +} + +private fun Map.toIdeInfo(): IdeInfo { + return IdeInfo( + product = IntelliJPlatformProduct.fromProductCode(this[IDE_PRODUCT_CODE]!!)!!, + buildNumber = this[IDE_BUILD_NUMBER]!! + ) +} + +private fun Map.toDeployTargetInfo(): DeployTargetInfo { + return if (!this[IDE_DOWNLOAD_LINK].isNullOrBlank()) DeployTargetInfo.DeployWithDownload( + URI(this[IDE_DOWNLOAD_LINK]), + null, + this.toIdeInfo() + ) + else DeployTargetInfo.NoDeploy(this[IDE_PATH_ON_HOST]!!, this.toIdeInfo()) +} + +private fun Map.workspaceHostname() = this[CODER_WORKSPACE_HOSTNAME]!! +private fun Map.projectPath() = this[PROJECT_PATH]!! + +fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection { + return if (!this[IDE_DOWNLOAD_LINK].isNullOrBlank()) RecentWorkspaceConnection( + this.workspaceHostname(), + this.projectPath(), + localTimeFormatter.format(LocalDateTime.now()), + this[IDE_PRODUCT_CODE]!!, + this[IDE_BUILD_NUMBER]!!, + this[IDE_DOWNLOAD_LINK]!!, + null, + this[WEB_TERMINAL_LINK]!! + ) else RecentWorkspaceConnection( + this.workspaceHostname(), + this.projectPath(), + localTimeFormatter.format(LocalDateTime.now()), + this[IDE_PRODUCT_CODE]!!, + this[IDE_BUILD_NUMBER]!!, + null, + this[IDE_PATH_ON_HOST], + this[WEB_TERMINAL_LINK]!! + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index c6cad9c2..707216aa 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -4,13 +4,14 @@ 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, terminalLink: String) : this() { + 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 } @@ -32,6 +33,10 @@ class RecentWorkspaceConnection() : BaseState(), Comparable Unit cs.launch { logger.info("Retrieving available IDE's for ${selectedWorkspace.name} workspace...") - val workspaceOS = if (selectedWorkspace.agentOS != null && selectedWorkspace.agentArch != null) withContext(Dispatchers.IO) { toDeployedOS(selectedWorkspace.agentOS, selectedWorkspace.agentArch) } else withContext(Dispatchers.IO) { + val hostAccessor = HighLevelHostAccessor.create( + RemoteCredentialsHolder().apply { + setHost("coder.${selectedWorkspace.name}") + userName = "coder" + authType = AuthType.OPEN_SSH + }, + true + ) + val workspaceOS = if (selectedWorkspace.agentOS != null && selectedWorkspace.agentArch != null) toDeployedOS(selectedWorkspace.agentOS, selectedWorkspace.agentArch) else withContext(Dispatchers.IO) { try { - val credentialsHolder = RemoteCredentialsHolder().apply { - setHost("coder.${selectedWorkspace.name}") - userName = "coder" - authType = AuthType.OPEN_SSH - } - HighLevelHostAccessor.create( - credentialsHolder, - true - ).hostCommandExecutor.guessOs() + hostAccessor.guessOs() } catch (e: Exception) { logger.error("Could not resolve any IDE for workspace ${selectedWorkspace.name}. Reason: $e") null @@ -142,6 +146,9 @@ class CoderLocateRemoteProjectStepView(private val disableNextAction: () -> Unit } } else { logger.info("Resolved OS and Arch for ${selectedWorkspace.name} is: $workspaceOS") + val installedIdes = withContext(Dispatchers.IO) { + hostAccessor.getInstalledIDEs().map { ide -> IdeWithStatus(ide.product, ide.buildNumber, IdeStatus.ALREADY_INSTALLED, null, ide.pathToIde, ide.presentableVersion, ide.remoteDevType) } + } val idesWithStatus = withContext(Dispatchers.IO) { IntelliJPlatformProduct.values() .filter { it.showInGateway } @@ -149,9 +156,17 @@ class CoderLocateRemoteProjectStepView(private val disableNextAction: () -> Unit .map { ide -> IdeWithStatus(ide.product, ide.buildNumber, IdeStatus.DOWNLOAD, ide.download, null, ide.presentableVersion, ide.remoteDevType) } } + if (installedIdes.isEmpty()) { + logger.info("No IDE is installed in workspace ${selectedWorkspace.name}") + } else { + ideComboBoxModel.addAll(installedIdes) + cbIDE.selectedIndex = 0 + } + if (idesWithStatus.isEmpty()) { logger.warn("Could not resolve any IDE for workspace ${selectedWorkspace.name}, probably $workspaceOS is not supported by Gateway") } else { + ideComboBoxModel.addAll(idesWithStatus) cbIDE.selectedIndex = 0 } @@ -183,18 +198,13 @@ class CoderLocateRemoteProjectStepView(private val disableNextAction: () -> Unit override fun onNext(wizardModel: CoderWorkspacesWizardModel): Boolean { val selectedIDE = cbIDE.selectedItem ?: return false - cs.launch { GatewayUI.getInstance().connect( - mapOf( - "type" to "coder", - "coder_workspace_hostname" to "coder.${wizardModel.selectedWorkspace?.name}", - "project_path" to tfProject.text, - "ide_product_code" to selectedIDE.product.productCode, - "ide_build_number" to selectedIDE.buildNumber, - "ide_download_link" to selectedIDE.download!!.link, - "web_terminal_link" to "${terminalLink.url}" - ) + selectedIDE + .toWorkspaceParams() + .withWorkspaceHostname("coder.${wizardModel.selectedWorkspace?.name}") + .withProjectPath(tfProject.text) + .withWebTerminalLink("${terminalLink.url}") ) } return true @@ -235,7 +245,7 @@ class CoderLocateRemoteProjectStepView(private val disableNextAction: () -> Unit JPanel().apply { layout = FlowLayout(FlowLayout.LEFT) add(JLabel(ideWithStatus.product.ideName, ideWithStatus.product.icon, SwingConstants.LEFT)) - add(JLabel("${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ideWithStatus.status.name.toLowerCase()}").apply { + add(JLabel("${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ideWithStatus.status.name.lowercase(Locale.getDefault())}").apply { foreground = UIUtil.getLabelDisabledForeground() }) background = UIUtil.getListBackground(isSelected, cellHasFocus)