Skip to content

Commit cac1571

Browse files
authored
feat: filter and scope the workspace list (#318)
Until now the plugin pulled back workspaces without applying any meaningful scope, so the list it showed was effectively the whole deployment. There was no way to narrow it down or to deliberately choose whose workspaces you wanted to see. This hit administrators hardest. On a large deployment the list could be huge, mixing together the workspaces of every user, with no quick way to focus on a particular person, template, or state. This change puts that choice in the user's hands. A scope selector switches between just your own workspaces and every workspace you're allowed to access, and the selection is remembered separately for each deployment so it sticks the next time you connect. Alongside it, a search box narrows the list by name, template, owner, or status, including quoted multi-word values and a plain-text shortcut that searches by workspace name. An explicit owner in the search always takes precedence over the selected scope. Changing the scope or the search reloads the list and regenerates SSH configuration to match what is shown. Deep links can now carry an owner as well, so a link to a workspace name that several users share resolves to the right one instead of guessing. When no owner is given the lookup stays unscoped, preserving the previous behavior. To keep the list manageable out of the box, it now defaults to showing only your own workspaces. Anyone who relied on seeing everything can switch the scope to all workspaces, and that preference is then remembered per deployment. - resolves DEVEX-372 - resolves DEVEX-374
1 parent 337aa50 commit cac1571

27 files changed

Lines changed: 707 additions & 60 deletions

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
- support Toolbox 3.5 provider header behavior
8+
- workspace list filtering that mirrors the Coder web dashboard
89

910
### Fixed
1011

@@ -14,7 +15,10 @@
1415
### Changed
1516

1617
- skip the Coder TLS alternate hostname when fetching IDE metadata from JetBrains
17-
- notifications are now persistent popups instead of snackbars, so they survive a hidden window and no longer get dropped
18+
- notifications are now persistent popups instead of snackbars, so they survive a hidden window and no longer get
19+
dropped
20+
- workspace lists now default to `My workspaces`, so users initially see only workspaces they own. Users can
21+
switch to `All workspaces`, and that selection is persisted per Coder deployment hostname.
1822

1923
## 0.9.0 - 2026-05-14
2024

README.md

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ jetbrains://gateway/com.coder.toolbox
7676
?url=http(s)://<your-coder-deployment>
7777
&token=<auth-token>
7878
&workspace=<workspace-name>
79+
&owner=<workspace-owner>
7980
&agent_name=<agent-name>
8081
&ide_product_code=<IDE-code>
8182
&ide_build_number=<IDE-build>
@@ -94,11 +95,16 @@ jetbrains://gateway/coder?url=http(s)://<your-coder-deployment>
9495
| url | Your Coder deployment URL (encoded) | Yes |
9596
| token | Coder authentication token | Yes |
9697
| workspace | Name of the Coder workspace to connect to. | Yes |
98+
| owner | Owner username, UUID, or `me` used to disambiguate the workspace lookup. | No |
9799
| agent_name | The name of the agent with the workspace | No |
98100
| ide_product_code | JetBrains IDE product code (e.g., GO for GoLand, RR for Rider) | No |
99101
| ide_build_number | Specific build number or placeholder (see below) of the JetBrains IDE to install on the workspace | No |
100102
| folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No |
101103

104+
When `owner` is present, the URI handler sends `owner:<owner>` as the workspace lookup query and then exact-matches the
105+
`workspace` value in the returned list. This lookup is independent of the workspace list filtering controls in the
106+
header. When `owner` is omitted, the URI handler sends no workspace lookup query.
107+
102108
> [!NOTE]
103109
> If only a single agent is available, specifying an agent name is optional. However, if multiple agents exist, you must
104110
> provide the
@@ -442,6 +448,53 @@ When reporting issues, include HTTP logs to help diagnose:
442448
The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data
443449
storage paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings.
444450

451+
### Workspace list filtering
452+
453+
The expandable header on the Coder Workspaces page mirrors the workspace filtering in the Coder web dashboard. The first
454+
row is a free-form search field; the second row holds three dropdowns: **Filters** (presets), **Template**, and
455+
**Status**.
456+
457+
- The search field holds
458+
a [Coder workspace filter query](https://coder.com/docs/user-guides/workspace-management#workspace-filtering)
459+
and is sent to the server verbatim as the `q` parameter. The server parses and validates it.
460+
- The list defaults to `owner:me` (your own workspaces) on each load. Nothing is persisted between sessions.
461+
- The dropdowns are shortcuts that edit the search text, exactly like the dashboard:
462+
- **Filters** replaces the whole query with the chosen preset: `My workspaces` (`owner:me`), `All workspaces`
463+
(empty), `Running workspaces` (`status:running`), `Failed workspaces` (`status:failed`),
464+
`Outdated workspaces` (`outdated:true`), `Shared workspaces` (`shared:true`), and `Dormant workspaces`
465+
(`dormant:true`). When the search text matches no preset, the dropdown shows `Custom`.
466+
- **Template** sets or replaces the `template:` term.
467+
- **Status** sets or replaces the `status:` term.
468+
- The dropdowns and the search text stay in sync: editing the text updates the dropdowns, and selecting a dropdown value
469+
updates the text. Because each key appears at most once, the dropdowns never produce a duplicate-key query.
470+
- If the server rejects the query (for example a duplicate key, or a malformed term), the validation message is shown in
471+
a secondary label after the dropdowns.
472+
473+
Examples of what is sent as the `q` parameter:
474+
475+
| Search field | Sent as `q` |
476+
|---------------------------|---------------------------|
477+
| empty | no `q` parameter |
478+
| `owner:me` (default) | `owner:me` |
479+
| `owner:me name:bobiverse` | `owner:me name:bobiverse` |
480+
| `template:"Heaven 3"` | `template:"Heaven 3"` |
481+
| `status:running` | `status:running` |
482+
483+
The Template dropdown is populated from the deployment's templates, fetched each time the header becomes visible.
484+
Changing the search or a dropdown refreshes the workspace list and regenerates SSH configuration from the refreshed
485+
workspace
486+
set.
487+
With wildcard SSH enabled, the generated block uses the deployment wildcard host pattern. With wildcard SSH disabled,
488+
the generated block contains entries for the currently resolved workspace/agent pairs.
489+
490+
SSH hostnames include the workspace owner so workspaces with the same name owned by different users remain distinct.
491+
With wildcard SSH enabled, the SSH config contains a deployment-wide `Host coder-jetbrains-toolbox-<host>--*` entry, and
492+
the plugin connects through hostnames shaped like
493+
`coder-jetbrains-toolbox-<host>--<owner>--<workspace>.<agent>`. With wildcard SSH disabled, the SSH config writes one
494+
explicit entry per resolved workspace/agent using
495+
`Host coder-jetbrains-toolbox--<owner>--<workspace>.<agent>--<host>`, and its proxy command targets
496+
`<owner>/<workspace>.<agent>`.
497+
445498
### CLI related settings
446499

447500
- `Binary source` specifies the source URL or relative path from which the Coder CLI should be downloaded.
@@ -549,7 +602,7 @@ support, may trigger regeneration of SSH configurations.
549602
JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements:
550603
- do **not** use Kotlin experimental APIs.
551604
- do **not** add any lambdas, handlers, or class handles to Java runtime hooks.
552-
- do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in
553-
the plugin's `CoderRemoteProvider#close()` method.
605+
- do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in
606+
the plugin's `CoderRemoteProvider#close()` method.
554607
- do **not** bundle libraries that are already provided by Toolbox.
555608
- do **not** perform any ill-intentioned actions.

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.9.0
1+
version=0.9.1
22
group=com.coder.toolbox
33
name=coder-toolbox

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ class CoderRemoteProvider(
6969
private var pollJob: Job? = null
7070
internal val lastEnvironments = mutableListOf<CoderRemoteEnvironment>()
7171

72-
private val triggerSshConfig = Channel<Boolean>(Channel.CONFLATED)
73-
private val triggerProviderVisible = Channel<Boolean>(Channel.CONFLATED)
72+
private val sshConfigTrigger = Channel<Boolean>(Channel.CONFLATED)
73+
private val workspaceRefreshTrigger = Channel<Boolean>(Channel.CONFLATED)
74+
private val providerVisibleTrigger = Channel<Boolean>(Channel.CONFLATED)
7475
private val dialogUi = DialogUi(context)
7576

7677
// The REST client, if we are signed in
@@ -82,8 +83,13 @@ class CoderRemoteProvider(
8283

8384
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
8485

85-
private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString()))
86-
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) {
86+
private val coderHeaderPage = NewEnvironmentPage(
87+
context,
88+
context.i18n.pnotr(context.deploymentUrl.toString()),
89+
workspaceRefreshTrigger,
90+
templatesProvider = { client?.templates() ?: emptyList() }
91+
)
92+
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, sshConfigTrigger) {
8793
client?.let { restClient ->
8894
if (context.settingsStore.useAppNameAsTitle) {
8995
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName))
@@ -170,13 +176,18 @@ class CoderRemoteProvider(
170176
onTimeout(POLL_INTERVAL) {
171177
context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout")
172178
}
173-
triggerSshConfig.onReceive { shouldTrigger ->
179+
sshConfigTrigger.onReceive { shouldTrigger ->
174180
if (shouldTrigger) {
175181
context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations")
176182
cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet())
177183
}
178184
}
179-
triggerProviderVisible.onReceive { isCoderProviderVisible ->
185+
workspaceRefreshTrigger.onReceive { shouldTrigger ->
186+
if (shouldTrigger) {
187+
context.logger.debug("workspace poller waked up to fetch workspaces from the latest header settings")
188+
}
189+
}
190+
providerVisibleTrigger.onReceive { isCoderProviderVisible ->
180191
if (isCoderProviderVisible) {
181192
context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses")
182193
}
@@ -199,7 +210,18 @@ class CoderRemoteProvider(
199210
client: CoderRestClient,
200211
cli: CoderCLIManager,
201212
): List<CoderRemoteEnvironment> {
202-
return client.workspaces().flatMap { ws ->
213+
val workspaces = try {
214+
client.workspaces(coderHeaderPage.workspaceSearchQuery.value)
215+
} catch (ex: APIResponseException) {
216+
// Surface invalid search queries on the header instead of failing the whole poll.
217+
if (ex.isValidationError) {
218+
coderHeaderPage.reportFilterError(ex.validationMessage)
219+
return emptyList()
220+
}
221+
throw ex
222+
}
223+
coderHeaderPage.reportFilterError(null)
224+
return workspaces.flatMap { ws ->
203225
// Agents are not included in workspaces that are off
204226
// so fetch them separately.
205227
val resources = when (ws.latestBuild.status) {
@@ -329,7 +351,7 @@ class CoderRemoteProvider(
329351
override fun setVisible(visibility: ProviderVisibilityState) {
330352
if (visibility.providerVisible) {
331353
context.cs.launch(CoroutineName("Notify Plugin Visibility")) {
332-
triggerProviderVisible.send(true)
354+
providerVisibleTrigger.send(true)
333355
}
334356
}
335357
}
@@ -356,12 +378,14 @@ class CoderRemoteProvider(
356378
if (sameUrl(newUrl, client?.url)) {
357379
coderHeaderPage.isBusy.update { true }
358380
try {
359-
if (context.settingsStore.requiresTokenAuth) {
381+
val activeSession = if (context.settingsStore.requiresTokenAuth) {
360382
newToken?.let {
361383
refreshSession(newUrl, it)
362-
}
384+
} ?: (this.client!! to this.cli!!)
385+
} else {
386+
this.client!! to this.cli!!
363387
}
364-
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
388+
linkHandler.handle(params, newUrl, activeSession.first, activeSession.second)
365389
} finally {
366390
coderHeaderPage.isBusy.update { false }
367391
}
@@ -507,6 +531,8 @@ class CoderRemoteProvider(
507531
this.client = newRestClient
508532
this.cli = newCli
509533
lastEnvironments.forEach { it.updateClientAndCli(newRestClient, newCli) }
534+
coderHeaderPage.resetFilter()
535+
context.cs.launch(CoroutineName("Load Templates")) { coderHeaderPage.reloadTemplates() }
510536
pollJob = poll(newRestClient, newCli)
511537
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
512538
return newRestClient to newCli
@@ -627,6 +653,8 @@ class CoderRemoteProvider(
627653
context.i18n.pnotr(client.me.username)
628654
}
629655
accountDropdownField.visibility.update { true }
656+
coderHeaderPage.resetFilter()
657+
context.cs.launch(CoroutineName("Load Templates")) { coderHeaderPage.reloadTemplates() }
630658
pollJob = poll(client, cli)
631659
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created")
632660
}

src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
1313
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
1414
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
1515
import com.jetbrains.toolbox.api.ui.ToolboxUi
16+
import com.jetbrains.toolbox.api.ui.components.UiComponents
1617
import kotlinx.coroutines.CancellationException
1718
import kotlinx.coroutines.CoroutineName
1819
import kotlinx.coroutines.CoroutineScope
@@ -22,6 +23,7 @@ import java.net.URL
2223
@Suppress("UnstableApiUsage")
2324
data class CoderToolboxContext(
2425
val ui: ToolboxUi,
26+
val uiComponents: UiComponents,
2527
val envPageManager: EnvironmentUiPageManager,
2628
val envStateColorPalette: EnvironmentStateColorPalette,
2729
val remoteIdeOrchestrator: RemoteToolsHelper,

src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
1818
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
1919
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
2020
import com.jetbrains.toolbox.api.ui.ToolboxUi
21+
import com.jetbrains.toolbox.api.ui.components.UiComponents
2122
import kotlinx.coroutines.CoroutineScope
2223

2324
/**
@@ -33,6 +34,7 @@ class CoderToolboxExtension : RemoteDevExtension {
3334
return CoderRemoteProvider(
3435
CoderToolboxContext(
3536
ui,
37+
serviceLocator.getService<UiComponents>(),
3638
serviceLocator.getService<EnvironmentUiPageManager>(),
3739
serviceLocator.getService<EnvironmentStateColorPalette>(),
3840
serviceLocator.getService<RemoteToolsHelper>(),

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,13 @@ open class CoderRestClient(
161161
}
162162

163163
/**
164-
* Retrieves the available workspaces created by the user.
164+
* Retrieves workspaces matching the provided Coder search query.
165165
* @throws [APIResponseException].
166166
*/
167-
suspend fun workspaces(): List<Workspace> {
168-
val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") }
167+
suspend fun workspaces(searchQuery: String? = null): List<Workspace> {
168+
val workspacesResponse = callWithRetry {
169+
retroRestClient.workspaces(searchQuery)
170+
}
169171
if (!workspacesResponse.isSuccessful) {
170172
throw APIResponseException(
171173
"retrieve workspaces",
@@ -241,6 +243,26 @@ open class CoderRestClient(
241243
}
242244
}
243245

246+
/**
247+
* Retrieves all templates the authenticated user can access.
248+
* @throws [APIResponseException].
249+
*/
250+
suspend fun templates(): List<Template> {
251+
val templatesResponse = callWithRetry { retroRestClient.templates() }
252+
if (!templatesResponse.isSuccessful) {
253+
throw APIResponseException(
254+
"retrieve templates",
255+
url,
256+
templatesResponse.code(),
257+
templatesResponse.parseErrorBody(moshi)
258+
)
259+
}
260+
261+
return requireNotNull(templatesResponse.body()) {
262+
"Successful response returned null body or templates"
263+
}
264+
}
265+
244266
/**
245267
* @throws [APIResponseException].
246268
*/
@@ -448,4 +470,4 @@ private fun Response<*>.parseErrorBody(moshi: Moshi): ApiErrorResponse? {
448470
} catch (e: Exception) {
449471
null
450472
}
451-
}
473+
}

src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ class APIResponseException(action: String, url: URL, code: Int, errorResponse: A
1212
val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code
1313
val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true
1414

15+
/** True when the server rejected the request because of bad input (e.g. an invalid search query). */
16+
val isValidationError = HttpURLConnection.HTTP_BAD_REQUEST == code && !errorResponse?.validations.isNullOrEmpty()
17+
18+
/**
19+
* A short, user-facing description of a validation failure, combining the server message with the
20+
* individual field details. Null when the response carried no validation errors.
21+
*/
22+
val validationMessage: String? = errorResponse?.validations
23+
?.takeIf { it.isNotEmpty() }
24+
?.joinToString(separator = " ", prefix = "${errorResponse.message} ") { it.detail }
25+
1526
companion object {
1627
private fun formatToPretty(
1728
action: String,

src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ interface CoderV2RestFacade {
3535
*/
3636
@GET("api/v2/workspaces")
3737
suspend fun workspaces(
38-
@Query("q") searchParams: String,
38+
@Query("q") searchParams: String?,
3939
): Response<WorkspacesResponse>
4040

4141
/**
@@ -58,6 +58,12 @@ interface CoderV2RestFacade {
5858
@Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest,
5959
): Response<WorkspaceBuild>
6060

61+
/**
62+
* Retrieves all templates the authenticated user can access.
63+
*/
64+
@GET("api/v2/templates")
65+
suspend fun templates(): Response<List<Template>>
66+
6167
@GET("api/v2/templates/{templateID}")
6268
suspend fun template(
6369
@Path("templateID") templateID: UUID,

src/main/kotlin/com/coder/toolbox/sdk/v2/models/ApiErrorResponse.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,11 @@ import com.squareup.moshi.JsonClass
77
data class ApiErrorResponse(
88
@Json(name = "message") val message: String,
99
@Json(name = "detail") val detail: String?,
10+
@Json(name = "validations") val validations: List<FieldValidation>? = null,
11+
)
12+
13+
@JsonClass(generateAdapter = true)
14+
data class FieldValidation(
15+
@Json(name = "field") val field: String,
16+
@Json(name = "detail") val detail: String,
1017
)

0 commit comments

Comments
 (0)