From 09b65517d1390d6ad570862622a610a3bc47c7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 15 May 2026 19:05:39 +0000 Subject: [PATCH 1/2] feat: shared workspaces support Add a `workspaceFilter` setting (defaults to `owner:me`) so users can broaden the listing to include workspaces shared with them. The URI handler now accepts an optional `owner` query parameter to disambiguate workspaces by name across owners, and the environment list surfaces the workspace owner as an environment information entry. Generated with Coder Agents on behalf of @aslilac. --- CHANGELOG.md | 3 +++ .../coder/toolbox/CoderRemoteEnvironment.kt | 3 ++- .../com/coder/toolbox/sdk/CoderRestClient.kt | 12 ++++++++-- .../toolbox/settings/ReadOnlyCoderSettings.kt | 7 ++++++ .../coder/toolbox/store/CoderSettingsStore.kt | 6 +++++ .../com/coder/toolbox/store/StoreKeys.kt | 1 + .../toolbox/util/CoderProtocolHandler.kt | 18 +++++++++++---- .../kotlin/com/coder/toolbox/util/LinkMap.kt | 3 +++ .../coder/toolbox/views/CoderSettingsPage.kt | 12 ++++++++++ .../coder/toolbox/CoderRemoteProviderTest.kt | 4 +++- .../kotlin/com/coder/toolbox/sdk/DataGen.kt | 3 ++- .../toolbox/settings/CoderSettingsTest.kt | 23 +++++++++++++++++++ 12 files changed, 86 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9e3f58..989dd3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - `Binary destination` can now point directly to an executable, used as-is; otherwise it is treated as a base directory as before - support for OAuth2 +- workspace listing now accepts a configurable `Workspace filter` (defaults to `owner:me`); leave it blank to include + workspaces shared with the current user +- the URI handler accepts an optional `owner` query parameter to disambiguate shared workspaces ### Removed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index a8b5fb61..805e3a49 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -64,7 +64,8 @@ class CoderRemoteEnvironment( MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) - override val additionalEnvironmentInformation: MutableMap = mutableMapOf() + override val additionalEnvironmentInformation: MutableMap = + mutableMapOf(context.i18n.ptrl("Owner") to workspace.ownerName) override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1d0f39c3..4e4c792d 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -161,11 +161,19 @@ open class CoderRestClient( } /** - * Retrieves the available workspaces created by the user. + * Retrieves the available workspaces visible to the current user. + * + * The query string is taken from + * [com.coder.toolbox.settings.ReadOnlyCoderSettings.workspaceFilter], which + * defaults to `owner:me`. Users can broaden it (for example to an empty + * string) to also include workspaces shared with them. + * * @throws [APIResponseException]. */ suspend fun workspaces(): List { - val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") } + val workspacesResponse = callWithRetry { + retroRestClient.workspaces(context.settingsStore.workspaceFilter) + } if (!workspacesResponse.isSuccessful) { throw APIResponseException( "retrieve workspaces", diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 2f8d82a5..2161fced 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -157,6 +157,13 @@ interface ReadOnlyCoderSettings { */ val workspaceCreateUrl: String? + /** + * The filter applied when listing workspaces, passed to the server as the + * `q` query parameter. Defaults to `owner:me`. Set to an empty string to + * include workspaces shared with the current user. + */ + val workspaceFilter: String + /** * The path where network information for SSH hosts are stored */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index ab6ea94a..68d6d9fb 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -88,6 +88,8 @@ class CoderSettingsStore( get() = store[WORKSPACE_VIEW_URL] override val workspaceCreateUrl: String? get() = store[WORKSPACE_CREATE_URL] + override val workspaceFilter: String + get() = store[WORKSPACE_FILTER] ?: "owner:me" /** * Where the specified deployment should put its data. @@ -262,6 +264,10 @@ class CoderSettingsStore( store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() } + fun updateWorkspaceFilter(filter: String) { + store[WORKSPACE_FILTER] = filter + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(getWinAppData(), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 69fea407..d3bbf455 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -53,6 +53,7 @@ internal const val NETWORK_INFO_DIR = "networkInfoDir" internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" +internal const val WORKSPACE_FILTER = "workspaceFilter" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index c6a2d942..3fa487a9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -46,7 +46,7 @@ open class CoderProtocolHandler( cli: CoderCLIManager ) { val workspaceName = resolveWorkspaceName(params) ?: return - val workspace = restClient.workspaces().matchName(workspaceName, url) + val workspace = restClient.workspaces().matchName(workspaceName, params.owner(), url) if (workspace != null) { if (!prepareWorkspace(workspace, restClient, cli, url)) return // we resolve the agent after the workspace is started otherwise we can get misleading @@ -82,12 +82,22 @@ open class CoderProtocolHandler( return workspace } - private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { - val workspace = this.firstOrNull { it.name == workspaceName } + private suspend fun List.matchName( + workspaceName: String, + owner: String?, + deploymentURL: URL, + ): Workspace? { + val candidates = this.filter { it.name == workspaceName } + val workspace = if (owner.isNullOrBlank()) { + candidates.firstOrNull() + } else { + candidates.firstOrNull { it.ownerName == owner } + } if (workspace == null) { + val descriptor = if (owner.isNullOrBlank()) workspaceName else "$owner/$workspaceName" context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "There is no workspace with name $workspaceName on $deploymentURL" + "There is no workspace with name $descriptor on $deploymentURL" ) return null } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index a343e14a..7eab4df1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" +const val OWNER = "owner" const val AGENT_NAME = "agent_name" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" @@ -14,6 +15,8 @@ fun Map.token() = this[TOKEN] fun Map.workspace() = this[WORKSPACE] +fun Map.owner() = this[OWNER] + fun Map.agentName() = this[AGENT_NAME] fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 934c5eca..20dad65d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -125,6 +125,12 @@ class CoderSettingsPage( TextType.General ) + private val workspaceFilterField = TextField( + context.i18n.ptrl("Workspace filter (leave blank to include shared workspaces)"), + settings.workspaceFilter, + TextType.General, + ) + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( @@ -134,6 +140,7 @@ class CoderSettingsPage( listOf( useAppNameField, disableAutostartField, + workspaceFilterField, httpLoggingField, ) ), @@ -214,6 +221,7 @@ class CoderSettingsPage( updateSshLogDir(sshLogDirField.contentState.value) updateNetworkInfoDir(networkInfoDirField.contentState.value) updateSshConfigOptions(sshExtraArgs.contentState.value) + updateWorkspaceFilter(workspaceFilterField.contentState.value) } } ) @@ -288,6 +296,10 @@ class CoderSettingsPage( settings.networkInfoDir } + workspaceFilterField.contentState.update { + settings.workspaceFilter + } + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update { diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index 55d65b84..c3332654 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -514,7 +514,8 @@ class CoderRemoteProviderTest { private fun mockWorkspace( name: String, status: WorkspaceStatus, - resources: List + resources: List, + ownerName: String = "owner", ): Workspace { val latestBuild = mockk { every { this@mockk.status } returns status @@ -522,6 +523,7 @@ class CoderRemoteProviderTest { } return mockk { every { this@mockk.name } returns name + every { this@mockk.ownerName } returns ownerName every { this@mockk.latestBuild } returns latestBuild every { this@mockk.templateDisplayName } returns name every { this@mockk.outdated } returns false diff --git a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt index da6b44e8..7aca0b05 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt @@ -40,6 +40,7 @@ class DataGen { name: String, templateID: UUID = UUID.randomUUID(), agents: Map = emptyMap(), + ownerName: String = "owner", ): Workspace { val wsId = UUID.randomUUID() return Workspace( @@ -54,7 +55,7 @@ class DataGen { ), outdated = false, name = name, - ownerName = "owner", + ownerName = ownerName, ) } diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index fa4da2df..2cb7332b 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -11,6 +11,7 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.store.TLS_CERT_PATH import com.coder.toolbox.store.TLS_KEY_PATH +import com.coder.toolbox.store.WORKSPACE_FILTER import com.coder.toolbox.util.OS import com.coder.toolbox.util.getOS import com.coder.toolbox.util.pluginTestSettingsStore @@ -280,6 +281,28 @@ internal class CoderSettingsTest { assertEquals(null, settings.readOnly().tls.caPath) assertEquals(null, settings.readOnly().tls.altHostname) assertEquals(getOS() == OS.MAC, settings.readOnly().disableAutostart) + assertEquals("owner:me", settings.readOnly().workspaceFilter) + } + + @Test + fun testWorkspaceFilter() { + // An empty value should be preserved so users can broaden the filter + // (for example to also list workspaces shared with them). + val settings = CoderSettingsStore( + pluginTestSettingsStore(WORKSPACE_FILTER to ""), + Environment(), + logger, + ) + assertEquals("", settings.readOnly().workspaceFilter) + + // Custom filters should round-trip through the store. + val customFilter = "owner:me OR shared:true" + val custom = CoderSettingsStore( + pluginTestSettingsStore(WORKSPACE_FILTER to customFilter), + Environment(), + logger, + ) + assertEquals(customFilter, custom.readOnly().workspaceFilter) } @Test From 60d4eaebe805931362af299d02ca39d8df0234fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 15 May 2026 20:03:47 +0000 Subject: [PATCH 2/2] Switch to two-query merge with owner-namespaced ids - Always query both `owner:me` and `shared:true`; merge and dedupe. - Drop the `workspaceFilter` setting. - Shared workspaces (where `ownerName != client.me.username`) use `..` as their environment id so they don't collide with the user's own workspaces; owned workspaces keep the legacy `.` id. --- CHANGELOG.md | 4 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 20 ++++++- .../com/coder/toolbox/CoderRemoteProvider.kt | 3 +- .../com/coder/toolbox/sdk/CoderRestClient.kt | 45 +++++++++----- .../toolbox/settings/ReadOnlyCoderSettings.kt | 7 --- .../coder/toolbox/store/CoderSettingsStore.kt | 6 -- .../com/coder/toolbox/store/StoreKeys.kt | 1 - .../toolbox/util/CoderProtocolHandler.kt | 3 +- .../coder/toolbox/views/CoderSettingsPage.kt | 12 ---- .../coder/toolbox/CoderRemoteProviderTest.kt | 21 +++++++ .../coder/toolbox/sdk/CoderRestClientTest.kt | 58 +++++++++++++++++++ .../toolbox/settings/CoderSettingsTest.kt | 23 -------- 12 files changed, 135 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989dd3bd..64dfa646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ - `Binary destination` can now point directly to an executable, used as-is; otherwise it is treated as a base directory as before - support for OAuth2 -- workspace listing now accepts a configurable `Workspace filter` (defaults to `owner:me`); leave it blank to include - workspaces shared with the current user +- workspaces shared with the current user via RBAC now appear in the workspace list alongside your own; shared + workspaces are namespaced by owner (`..`) so they don't collide with yours - the URI handler accepts an optional `owner` query parameter to disambiguate shared workspaces ### Removed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 805e3a49..1245cfca 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -42,6 +42,20 @@ import kotlin.time.Duration.Companion.seconds private val POLL_INTERVAL = 5.seconds +/** + * Build the environment id for a workspace/agent pair. Workspaces shared + * with the current user are namespaced by owner (`..`) + * so they don't collide with the user's own workspaces, while owned + * workspaces keep the legacy `.` id to preserve persistent + * per-environment state (auto-connect, etc.). + */ +fun environmentId(workspace: Workspace, agent: WorkspaceAgent, currentUser: String): String = + if (workspace.ownerName == currentUser) { + "${workspace.name}.${agent.name}" + } else { + "${workspace.ownerName}.${workspace.name}.${agent.name}" + } + /** * Represents an agent and workspace combination. * @@ -53,10 +67,12 @@ class CoderRemoteEnvironment( internal var cli: CoderCLIManager, private var workspace: Workspace, private var agent: WorkspaceAgent, -) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { +) : RemoteProviderEnvironment(environmentId(workspace, agent, client.me.username)), + BeforeConnectionHook, + AfterDisconnectHook { private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent) - override var name: String = "${workspace.name}.${agent.name}" + override var name: String = environmentId(workspace, agent, client.me.username) private var isConnected: MutableStateFlow = MutableStateFlow(false) override val connectionRequest: MutableStateFlow = MutableStateFlow(false) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 972497ec..782f2005 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -214,7 +214,8 @@ class CoderRemoteProvider( .flatMap { it.agents ?: emptyList() } .distinctBy { it.name } .map { agent -> - lastEnvironments.firstOrNull { it.id == "${ws.name}.${agent.name}" } + val envId = environmentId(ws, agent, client.me.username) + lastEnvironments.firstOrNull { it.id == envId } ?.also { // If we have an environment already, update that. it.update(ws, agent) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 4e4c792d..4faba05a 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -163,27 +163,46 @@ open class CoderRestClient( /** * Retrieves the available workspaces visible to the current user. * - * The query string is taken from - * [com.coder.toolbox.settings.ReadOnlyCoderSettings.workspaceFilter], which - * defaults to `owner:me`. Users can broaden it (for example to an empty - * string) to also include workspaces shared with them. + * Runs two queries against the server, `owner:me` (workspaces owned by the + * current user) and `shared:true` (workspaces shared with them via RBAC), + * and returns the union deduplicated by workspace id. The `shared:true` + * query is best-effort: if the server does not understand it (older + * deployments) we log the error and return only the owned set. * - * @throws [APIResponseException]. + * @throws [APIResponseException] when the `owner:me` query fails. */ suspend fun workspaces(): List { - val workspacesResponse = callWithRetry { - retroRestClient.workspaces(context.settingsStore.workspaceFilter) + val owned = fetchWorkspaces("owner:me") + val shared = try { + fetchWorkspaces("shared:true") + } catch (ex: APIResponseException) { + context.logger.warn(ex, "Failed to list shared workspaces on $url; continuing with owned workspaces only") + emptyList() + } + + if (shared.isEmpty()) return owned + + // Dedupe by id in case the server returns a workspace in both queries + // (for example if a user shares a workspace with themselves). + val seen = HashSet(owned.size + shared.size) + return buildList { + for (ws in owned + shared) { + if (seen.add(ws.id)) add(ws) + } } - if (!workspacesResponse.isSuccessful) { + } + + private suspend fun fetchWorkspaces(query: String): List { + val response = callWithRetry { retroRestClient.workspaces(query) } + if (!response.isSuccessful) { throw APIResponseException( - "retrieve workspaces", + "retrieve workspaces matching '$query'", url, - workspacesResponse.code(), - workspacesResponse.parseErrorBody(moshi) + response.code(), + response.parseErrorBody(moshi) ) } - - return requireNotNull(workspacesResponse.body()?.workspaces) { + return requireNotNull(response.body()?.workspaces) { "Successful response returned null body or workspaces" } } diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 2161fced..2f8d82a5 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -157,13 +157,6 @@ interface ReadOnlyCoderSettings { */ val workspaceCreateUrl: String? - /** - * The filter applied when listing workspaces, passed to the server as the - * `q` query parameter. Defaults to `owner:me`. Set to an empty string to - * include workspaces shared with the current user. - */ - val workspaceFilter: String - /** * The path where network information for SSH hosts are stored */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 68d6d9fb..ab6ea94a 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -88,8 +88,6 @@ class CoderSettingsStore( get() = store[WORKSPACE_VIEW_URL] override val workspaceCreateUrl: String? get() = store[WORKSPACE_CREATE_URL] - override val workspaceFilter: String - get() = store[WORKSPACE_FILTER] ?: "owner:me" /** * Where the specified deployment should put its data. @@ -264,10 +262,6 @@ class CoderSettingsStore( store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() } - fun updateWorkspaceFilter(filter: String) { - store[WORKSPACE_FILTER] = filter - } - private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(getWinAppData(), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index d3bbf455..69fea407 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -53,7 +53,6 @@ internal const val NETWORK_INFO_DIR = "networkInfoDir" internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" -internal const val WORKSPACE_FILTER = "workspaceFilter" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3fa487a9..3c078968 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.environmentId import com.coder.toolbox.feed.IdeFeedManager import com.coder.toolbox.feed.IdeType import com.coder.toolbox.models.WorkspaceAndAgentStatus @@ -59,7 +60,7 @@ open class CoderProtocolHandler( ) ?: return if (!ensureAgentIsReady(workspace, agent)) return delay(2.seconds) - val environmentId = "${workspace.name}.${agent.name}" + val environmentId = environmentId(workspace, agent, restClient.me.username) context.showEnvironmentPage(environmentId) val productCode = params.ideProductCode() diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 20dad65d..934c5eca 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -125,12 +125,6 @@ class CoderSettingsPage( TextType.General ) - private val workspaceFilterField = TextField( - context.i18n.ptrl("Workspace filter (leave blank to include shared workspaces)"), - settings.workspaceFilter, - TextType.General, - ) - private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( @@ -140,7 +134,6 @@ class CoderSettingsPage( listOf( useAppNameField, disableAutostartField, - workspaceFilterField, httpLoggingField, ) ), @@ -221,7 +214,6 @@ class CoderSettingsPage( updateSshLogDir(sshLogDirField.contentState.value) updateNetworkInfoDir(networkInfoDirField.contentState.value) updateSshConfigOptions(sshExtraArgs.contentState.value) - updateWorkspaceFilter(workspaceFilterField.contentState.value) } } ) @@ -296,10 +288,6 @@ class CoderSettingsPage( settings.networkInfoDir } - workspaceFilterField.contentState.update { - settings.workspaceFilter - } - visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update { diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index c3332654..009e25a4 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -44,6 +44,10 @@ class CoderRemoteProviderTest { mockCli = mockk(relaxed = true) mockContext = mockk(relaxed = true) remoteProvider = CoderRemoteProvider(mockContext) + // Default mocked client is the owner of every mocked workspace so the + // env id stays in the legacy `.` form. Tests that + // exercise shared workspaces override `mockClient.me.username`. + every { mockClient.me.username } returns "owner" } @AfterTest @@ -495,6 +499,23 @@ class CoderRemoteProviderTest { assertEquals("workspace2.mockAgent", result[1].id) } + @Test + fun `given a workspace owned by someone else then env id is namespaced by owner`() = runTest { + // given + val agent = mockAgent("agent1") + val resource = mockResource(agents = listOf(agent)) + val owned = mockWorkspace("mine", WorkspaceStatus.RUNNING, listOf(resource), ownerName = "me") + val shared = mockWorkspace("shared", WorkspaceStatus.RUNNING, listOf(resource), ownerName = "coworker") + every { mockClient.me.username } returns "me" + coEvery { mockClient.workspaces() } returns listOf(owned, shared) + + // when + val result = remoteProvider.resolveWorkspaceEnvironments(mockClient, mockCli) + + // then + assertEquals(setOf("mine.agent1", "coworker.shared.agent1"), result.map { it.id }.toSet()) + } + // Helper functions private fun mockAgent(name: String, status: WorkspaceAgentStatus = WorkspaceAgentStatus.CONNECTED): WorkspaceAgent { return mockk { diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index a91e7baa..f7a7a83f 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -289,6 +289,64 @@ class CoderRestClientTest { } } + @Test + fun testMergesOwnedAndSharedWorkspaces() { + val owned = DataGen.workspace("owned", ownerName = "me") + val shared = DataGen.workspace("shared", ownerName = "coworker") + val (srv, url) = mockServer() + val client = CoderRestClient(context, URL(url), "token") + srv.createContext( + "/api/v2/workspaces", + BaseHttpHandler("GET") { exchange -> + val query = exchange.requestURI.rawQuery.orEmpty() + val workspaces = when { + query.contains("owner%3Ame") -> listOf(owned) + query.contains("shared%3Atrue") -> listOf(shared, owned) + else -> emptyList() + } + val body = moshi.adapter(WorkspacesResponse::class.java) + .toJson(WorkspacesResponse(workspaces)) + .toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }, + ) + + val result = runBlocking { client.workspaces() } + assertEquals(listOf("owned", "shared"), result.map { it.name }) + srv.stop(0) + } + + @Test + fun testIgnoresFailedSharedQuery() { + val owned = DataGen.workspace("owned", ownerName = "me") + val (srv, url) = mockServer() + val client = CoderRestClient(context, URL(url), "token") + srv.createContext( + "/api/v2/workspaces", + BaseHttpHandler("GET") { exchange -> + val query = exchange.requestURI.rawQuery.orEmpty() + if (query.contains("shared%3Atrue")) { + val response = Response("Bad request", "shared:true is not supported") + val body = moshi.adapter(Response::class.java).toJson(response).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_BAD_REQUEST, body.size.toLong()) + exchange.responseBody.write(body) + return@BaseHttpHandler + } + val body = moshi.adapter(WorkspacesResponse::class.java) + .toJson(WorkspacesResponse(listOf(owned))) + .toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }, + ) + + val result = runBlocking { client.workspaces() } + assertEquals(listOf("owned"), result.map { it.name }) + srv.stop(0) + } + + @Test fun testGetsResources() { val tests = diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 2cb7332b..fa4da2df 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -11,7 +11,6 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.store.TLS_CERT_PATH import com.coder.toolbox.store.TLS_KEY_PATH -import com.coder.toolbox.store.WORKSPACE_FILTER import com.coder.toolbox.util.OS import com.coder.toolbox.util.getOS import com.coder.toolbox.util.pluginTestSettingsStore @@ -281,28 +280,6 @@ internal class CoderSettingsTest { assertEquals(null, settings.readOnly().tls.caPath) assertEquals(null, settings.readOnly().tls.altHostname) assertEquals(getOS() == OS.MAC, settings.readOnly().disableAutostart) - assertEquals("owner:me", settings.readOnly().workspaceFilter) - } - - @Test - fun testWorkspaceFilter() { - // An empty value should be preserved so users can broaden the filter - // (for example to also list workspaces shared with them). - val settings = CoderSettingsStore( - pluginTestSettingsStore(WORKSPACE_FILTER to ""), - Environment(), - logger, - ) - assertEquals("", settings.readOnly().workspaceFilter) - - // Custom filters should round-trip through the store. - val customFilter = "owner:me OR shared:true" - val custom = CoderSettingsStore( - pluginTestSettingsStore(WORKSPACE_FILTER to customFilter), - Environment(), - logger, - ) - assertEquals(customFilter, custom.readOnly().workspaceFilter) } @Test