diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db69cb..acf8504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,12 @@ ### Fixed - SSH connection to a Workspace is no longer established only once +- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder deployment ### Changed - action buttons on the token input step were swapped to achieve better keyboard navigation - -### Fixed - -- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder deployment +- URI `project_path` query parameter was renamed to `folder` ## 0.1.3 - 2025-04-09 diff --git a/README.md b/README.md index 6823fae..7a65a03 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,109 @@ -# Toolbox Gateway plugin sample +# Coder Toolbox plugin -To load plugin into the provided Toolbox App, run `./gradlew build copyPlugin` +[!["Join us onDiscord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=purple)](https://discord.gg/coder) +[![Twitter Follow](https://img.shields.io/twitter/follow/CoderHQ?label=%40CoderHQ&style=social)](https://twitter.com/coderhq) +[![Coder Toolbox Plugin Build](https://github.com/coder/coder-jetbrains-toolbox/actions/workflows/build.yml/badge.svg)](https://github.com/coder/coder-jetbrains-toolbox/actions/workflows/build.yml) -or put files in the following directory: +Connects your JetBrains IDE to Coder workspaces -* Windows: `%LocalAppData%/JetBrains/Toolbox/cache/plugins/plugin-id` -* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/plugin-id` -* Linux: `~/.local/share/JetBrains/Toolbox/plugins/plugin-id` +## Getting Started -Put all required .jar files (do not include any dependencies already included with the Toolbox App to avoid possible resolution conflicts), -`extensions.json` and `icon.svg` in this directory. +To install this plugin using JetBrains Toolbox, follow the steps below. + +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40284` release or + above. +2. Launch the Toolbox app and sign in with your JetBrains account (if needed). + +### Install Coder plugin via URI + +You can quickly install the plugin using this JetBrains hyperlink. + +👉 [Install plugin](jetbrains://gateway/com.coder.toolbox) + +This will open JetBrains Toolbox and prompt you to install the Coder Toolbox plugin automatically. + +Alternatively, you can paste `jetbrains://gateway/com.coder.toolbox` into a browser. + +### Manual install + +There are two ways Coder Toolbox plugin can be installed. The first option is to manually download the plugin +artifact from [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/26968-coder/versions) +or from [Coder's Github Release page](https://github.com/coder/coder-jetbrains-toolbox/releases). + +The next step is to copy the zip content to one of the following locations, depending on your OS: + +* Windows: `%LocalAppData%/JetBrains/Toolbox/plugins/com.coder.toolbox` +* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/com.coder.toolbox` +* Linux: `~/.local/share/JetBrains/Toolbox/plugins/com.coder.toolbox` + +Alternatively, you can install it using the _Gradle_ tasks included in the project: + +```shell + +./gradlew cleanAll build copyPlugin +``` + +Make sure Toolbox is closed before running the command. + +## Connect to a Coder Workspace via JetBrains Toolbox URI + +You can use specially crafted JetBrains Gateway URIs to automatically: + +1. Open Toolbox + +2. Install the Coder Toolbox plugin (if not already installed) + +3. Connect to a specific Coder deployment using a URL and a token. + +4. Select a running workspace + +5. Install a specified JetBrains IDE on that Workspace + +6. Open a project folder directly in the remote IDE + +### Example URIs + +```text +jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs + +jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +``` + +### URI Breakdown + +```text +jetbrains://gateway/com.coder.toolbox + ?url=http(s):// + &token= + &workspace= + &agent_id= + &ide_product_code= + &ide_build_number= + &folder= +``` + +| Query param | Description | Mandatory | +|------------------|------------------------------------------------------------------------------|-----------| +| url | Your Coder deployment URL (encoded) | Yes | +| token | Coder authentication token | Yes | +| workspace | Name of the Coder workspace to connect to. | Yes | +| agent_id | ID of the agent associated with the workspace | No | +| ide_product_code | JetBrains IDE product code (e.g., GO for GoLand, RR for Rider) | No | +| ide_build_number | Specific build number of the JetBrains IDE to install on the workspace | No | +| folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No | + +If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist, +you must provide either the ID to target a specific one. Note that this version of the Coder Toolbox plugin +does not automatically start agents if they are offline, so please ensure the selected agent is running before +proceeding. + +If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open and highlight the workspace environment +page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable +experience, it’s recommended to ensure the workspace is running prior to initiating the connection. + +## Releasing + +1. Check that the changelog lists all the important changes. +2. Update the gradle.properties version. +3. Publish the resulting draft release after validating it. +4. Merge the resulting changelog PR. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e715675..9c81da9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -191,8 +191,10 @@ private fun getPluginInstallDir(): Path { } / "JetBrains" / "Toolbox" val pluginsDir = when { - SystemInfoRt.isWindows -> toolboxCachesDir / "cache" - SystemInfoRt.isLinux || SystemInfoRt.isMac -> toolboxCachesDir + SystemInfoRt.isWindows || + SystemInfoRt.isLinux || + SystemInfoRt.isMac -> toolboxCachesDir + else -> error("Unknown os") } / "plugins" diff --git a/gradle.properties b/gradle.properties index d69a4d7..438b864 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.1.3 +version=0.1.4 group=com.coder.toolbox name=coder-toolbox diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index c8b8e51..de79422 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -42,7 +42,12 @@ open class CoderProtocolHandler( shouldWaitForAutoLogin: Boolean, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { + context.popupPluginMainPage() val params = uri.toQueryParameters() + if (params.isEmpty()) { + // probably a plugin installation scenario + return + } val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { @@ -123,7 +128,19 @@ open class CoderProtocolHandler( } // TODO: Show a dropdown and ask for an agent if missing. - val agent = getMatchingAgent(params, workspace) + val agent: WorkspaceAgent + try { + agent = getMatchingAgent(params, workspace) + } catch (e: IllegalArgumentException) { + context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") + context.showErrorPopup( + MissingArgumentException( + "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", + e + ) + ) + return + } val status = WorkspaceAndAgentStatus.from(workspace, agent) if (!status.ready()) { @@ -157,7 +174,7 @@ open class CoderProtocolHandler( context.envPageManager.showEnvironmentPage(environmentId, false) val productCode = params.ideProductCode() val buildNumber = params.ideBuildNumber() - val projectPath = params.projectPath() + val projectFolder = params.projectFolder() if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { context.cs.launch { val ideVersion = "$productCode-$buildNumber" @@ -167,7 +184,7 @@ open class CoderProtocolHandler( } job.join() context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectPath) + context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder) } } } @@ -262,10 +279,8 @@ internal fun resolveRedirects(url: URL): URL { /** * Return the agent matching the provided agent ID or name in the parameters. - * The name is ignored if the ID is set. If neither was supplied and the - * workspace has only one agent, return that. Otherwise throw an error. * - * @throws [MissingArgumentException, IllegalArgumentException] + * @throws [IllegalArgumentException] */ internal fun getMatchingAgent( parameters: Map, @@ -281,8 +296,6 @@ internal fun getMatchingAgent( val agent = if (!parameters.agentID().isNullOrBlank()) { agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (!parameters.agentName().isNullOrBlank()) { - agents.firstOrNull { it.name == parameters.agentName() } } else if (agents.size == 1) { agents.first() } else { @@ -292,13 +305,9 @@ internal fun getMatchingAgent( if (agent == null) { if (!parameters.agentID().isNullOrBlank()) { throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else if (!parameters.agentName().isNullOrBlank()) { - throw IllegalArgumentException( - "The workspace \"${workspace.name}\"does not have an agent named \"${parameters.agentName()}\"", - ) } else { throw MissingArgumentException( - "Unable to determine which agent to connect to; one of \"$AGENT_NAME\" or \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", + "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", ) } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 9e2ef49..1135227 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -1,7 +1,5 @@ package com.coder.toolbox.util -// These are keys that we support in our Gateway links and must not be changed. -private const val TYPE = "type" const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" @@ -9,12 +7,7 @@ const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" -private const val PROJECT_PATH = "project_path" - -// Helper functions for reading from the map. Prefer these to directly -// interacting with the map. - -fun Map.isCoder(): Boolean = this[TYPE] == "coder" +private const val FOLDER = "folder" fun Map.url() = this[URL] @@ -22,12 +15,10 @@ fun Map.token() = this[TOKEN] fun Map.workspace() = this[WORKSPACE] -fun Map.agentName() = this[AGENT_NAME] - fun Map.agentID() = this[AGENT_ID] fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] fun Map.ideBuildNumber() = this[IDE_BUILD_NUMBER] -fun Map.projectPath() = this[PROJECT_PATH] +fun Map.projectFolder() = this[FOLDER] diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt index 61ecc65..bb87151 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt @@ -54,16 +54,21 @@ internal class LinkHandlerTest { val tests = listOf( - Pair(mapOf("agent" to "agent_name"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent" to "agent_name_2"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent" to "agent_name_3"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - Pair(mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + Pair( + mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), + "9a920eee-47fb-4571-9501-e4b3120c12f2" + ), + Pair( + mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), + "fb3daea4-da6b-424d-84c7-36b90574cfef" + ), + Pair( + mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + "b0e4c54d-9ba9-4413-8512-11ca1e826a24" + ), // Prefer agent_id. Pair( mapOf( - "agent" to "agent_name", "agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ), "b0e4c54d-9ba9-4413-8512-11ca1e826a24", @@ -81,15 +86,14 @@ internal class LinkHandlerTest { val tests = listOf( Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to ""), MissingArgumentException::class, "Unable to determine"), Triple(mapOf("agent_id" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to null), MissingArgumentException::class, "Unable to determine"), Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), Triple(mapOf("agent_id" to "not-a-uuid"), IllegalArgumentException::class, "agent with ID"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), + Triple( + mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + IllegalArgumentException::class, + "agent with ID" + ), // Will ignore agent if agent_id is set even if agent matches. Triple( mapOf( @@ -139,10 +143,11 @@ internal class LinkHandlerTest { val ws = DataGen.workspace("ws", agents = oneAgent) val tests = listOf( - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name_3"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), + Triple( + mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + IllegalArgumentException::class, + "agent with ID" + ), ) tests.forEach { @@ -166,7 +171,11 @@ internal class LinkHandlerTest { Triple(mapOf("agent" to null), IllegalArgumentException::class, "has no agents"), Triple(mapOf("agent_id" to null), IllegalArgumentException::class, "has no agents"), Triple(mapOf("agent" to "agent_name"), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), IllegalArgumentException::class, "has no agents"), + Triple( + mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), + IllegalArgumentException::class, + "has no agents" + ), ) tests.forEach {