Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: centralize all toolbox services in a context
- service dependencies were resolved all over the place making refactoring harder
- it promoted implicit, hidden dependencies
- and also introduced tighter coupling between components.
- in some cases we had to provide some i18n strings from upstream because the localization service
  was not available in the constructor.

 With this patch we resolve all the needed services during plugin load, wrap them in a context and inject
 the context via the constructor. It is now easier to refactor and the number of constructor parameters has
 been reduced.
  • Loading branch information
fioan89 committed Mar 6, 2025
commit d75d0c73d7e782b1a9e424deecb66dc1c7a3ef28
74 changes: 32 additions & 42 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,12 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.util.withPath
import com.coder.toolbox.views.Action
import com.coder.toolbox.views.EnvironmentView
import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState
import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription
import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -36,58 +31,54 @@ import kotlin.time.Duration.Companion.seconds
* Used in the environment list view.
*/
class CoderRemoteEnvironment(
private val serviceLocator: ServiceLocator,
private val context: CoderToolboxContext,
private val client: CoderRestClient,
private var workspace: Workspace,
private var agent: WorkspaceAgent,
private var cs: CoroutineScope,
) : RemoteProviderEnvironment("${workspace.name}.${agent.name}") {
private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)

private val ui: ToolboxUi = serviceLocator.getService(ToolboxUi::class.java)
private val i18n = serviceLocator.getService(LocalizableStringFactory::class.java)

override var name: String = "${workspace.name}.${agent.name}"
override val state: MutableStateFlow<RemoteEnvironmentState> =
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(serviceLocator))
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context))
override val description: MutableStateFlow<EnvironmentDescription> =
MutableStateFlow(EnvironmentDescription.General(i18n.pnotr(workspace.templateName)))
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateName)))

override val actionsList: StateFlow<List<ActionDescription>> = MutableStateFlow(
listOf(
Action(i18n.ptrl("Open web terminal")) {
cs.launch {
Action(context.i18n.ptrl("Open web terminal")) {
context.cs.launch {
BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) {
ui.showErrorInfoPopup(it)
context.ui.showErrorInfoPopup(it)
}
}
},
Action(i18n.ptrl("Open in dashboard")) {
cs.launch {
Action(context.i18n.ptrl("Open in dashboard")) {
context.cs.launch {
BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) {
ui.showErrorInfoPopup(it)
context.ui.showErrorInfoPopup(it)
}
}
},

Action(i18n.ptrl("View template")) {
cs.launch {
Action(context.i18n.ptrl("View template")) {
context.cs.launch {
BrowserUtil.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) {
ui.showErrorInfoPopup(it)
context.ui.showErrorInfoPopup(it)
}
}
},
Action(i18n.ptrl("Start"), enabled = { wsRawStatus.canStart() }) {
Action(context.i18n.ptrl("Start"), enabled = { wsRawStatus.canStart() }) {
val build = client.startWorkspace(workspace)
workspace = workspace.copy(latestBuild = build)
update(workspace, agent)
},
Action(i18n.ptrl("Stop"), enabled = { wsRawStatus.canStop() }) {
Action(context.i18n.ptrl("Stop"), enabled = { wsRawStatus.canStop() }) {
val build = client.stopWorkspace(workspace)
workspace = workspace.copy(latestBuild = build)
update(workspace, agent)
},
Action(i18n.ptrl("Update"), enabled = { workspace.outdated }) {
Action(context.i18n.ptrl("Update"), enabled = { workspace.outdated }) {
val build = client.updateWorkspace(workspace)
workspace = workspace.copy(latestBuild = build)
update(workspace, agent)
Expand All @@ -101,9 +92,9 @@ class CoderRemoteEnvironment(
this.workspace = workspace
this.agent = agent
wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
cs.launch {
context.cs.launch {
state.update {
wsRawStatus.toRemoteEnvironmentState(serviceLocator)
wsRawStatus.toRemoteEnvironmentState(context)
}
}
}
Expand Down Expand Up @@ -137,43 +128,42 @@ class CoderRemoteEnvironment(
// }

override fun onDelete() {
cs.launch {
context.cs.launch {
// TODO info and cancel pop-ups only appear on the main page where all environments are listed.
// However, #showSnackbar works on other pages. Until JetBrains fixes this issue we are going to use the snackbar
val shouldDelete = if (wsRawStatus.canStop()) {
ui.showOkCancelPopup(
i18n.ptrl("Delete running workspace?"),
i18n.ptrl("Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical."),
i18n.ptrl("Delete"),
i18n.ptrl("Cancel")
context.ui.showOkCancelPopup(
context.i18n.ptrl("Delete running workspace?"),
context.i18n.ptrl("Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical."),
context.i18n.ptrl("Delete"),
context.i18n.ptrl("Cancel")
)
} else {
ui.showOkCancelPopup(
i18n.ptrl("Delete workspace?"),
i18n.ptrl("All the information in this workspace will be lost, including all files, unsaved changes and historical."),
i18n.ptrl("Delete"),
i18n.ptrl("Cancel")
context.ui.showOkCancelPopup(
context.i18n.ptrl("Delete workspace?"),
context.i18n.ptrl("All the information in this workspace will be lost, including all files, unsaved changes and historical."),
context.i18n.ptrl("Delete"),
context.i18n.ptrl("Cancel")
)
}
if (shouldDelete) {
try {
client.removeWorkspace(workspace)
cs.launch {
context.cs.launch {
withTimeout(5.minutes) {
var workspaceStillExists = true
while (cs.isActive && workspaceStillExists) {
while (context.cs.isActive && workspaceStillExists) {
if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) {
workspaceStillExists = false
serviceLocator.getService(EnvironmentUiPageManager::class.java)
.showPluginEnvironmentsPage()
context.envPageManager.showPluginEnvironmentsPage()
} else {
delay(1.seconds)
}
}
}
}
} catch (e: APIResponseException) {
ui.showErrorInfoPopup(e)
context.ui.showErrorInfoPopup(e)
}
}
}
Expand Down
81 changes: 31 additions & 50 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.coder.toolbox

import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.logger.CoderLoggerFactory
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.coder.toolbox.services.CoderSecretsService
Expand All @@ -17,20 +16,13 @@ import com.coder.toolbox.views.ConnectPage
import com.coder.toolbox.views.NewEnvironmentPage
import com.coder.toolbox.views.SignInPage
import com.coder.toolbox.views.TokenPage
import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.util.LoadableState
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.jetbrains.toolbox.api.ui.components.UiPage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -47,29 +39,20 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM
import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory

class CoderRemoteProvider(
private val serviceLocator: ServiceLocator,
private val context: CoderToolboxContext,
private val httpClient: OkHttpClient,
) : RemoteProvider("Coder") {
private val logger = CoderLoggerFactory.getLogger(javaClass)

private val ui: ToolboxUi = serviceLocator.getService(ToolboxUi::class.java)
private val coroutineScope: CoroutineScope = serviceLocator.getService(CoroutineScope::class.java)
private val settingsStore: PluginSettingsStore = serviceLocator.getService(PluginSettingsStore::class.java)
private val secretsStore: PluginSecretStore = serviceLocator.getService(PluginSecretStore::class.java)
private val i18n = serviceLocator.getService(LocalizableStringFactory::class.java)

// Current polling job.
private var pollJob: Job? = null
private var lastEnvironments: Set<CoderRemoteEnvironment>? = null

// Create our services from the Toolbox ones.
private val settingsService = CoderSettingsService(settingsStore)
private val settingsService = CoderSettingsService(context.settingsStore)
private val settings: CoderSettings = CoderSettings(settingsService)
private val secrets: CoderSecretsService = CoderSecretsService(secretsStore)
private val settingsPage: CoderSettingsPage =
CoderSettingsPage(serviceLocator, settingsService, i18n.ptrl("Coder Settings"))
private val dialogUi = DialogUi(serviceLocator, settings)
private val linkHandler = LinkHandler(serviceLocator, settings, httpClient, dialogUi)
private val secrets: CoderSecretsService = CoderSecretsService(context.secretsStore)
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, settingsService)
private val dialogUi = DialogUi(context, settings)
private val linkHandler = LinkHandler(context, settings, httpClient, dialogUi)

// The REST client, if we are signed in
private var client: CoderRestClient? = null
Expand All @@ -91,10 +74,10 @@ class CoderRemoteProvider(
* workspace is added, reconfigure SSH using the provided cli (including the
* first time).
*/
private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = coroutineScope.launch {
private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch {
while (isActive) {
try {
logger.debug("Fetching workspace agents from {}", client.url)
context.logger.debug("Fetching workspace agents from ${client.url}")
val resolvedEnvironments = client.workspaces().flatMap { ws ->
// Agents are not included in workspaces that are off
// so fetch them separately.
Expand All @@ -111,7 +94,7 @@ class CoderRemoteProvider(
it.name
}?.map { agent ->
// If we have an environment already, update that.
val env = CoderRemoteEnvironment(serviceLocator, client, ws, agent, coroutineScope)
val env = CoderRemoteEnvironment(context, client, ws, agent)
lastEnvironments?.firstOrNull { it == env }?.let {
it.update(ws, agent)
it
Expand All @@ -131,7 +114,7 @@ class CoderRemoteProvider(
?.let { resolvedEnvironments.subtract(it) }
?: resolvedEnvironments
if (newEnvironments.isNotEmpty()) {
logger.info("Found new environment(s), reconfiguring CLI: {}", newEnvironments)
context.logger.info("Found new environment(s), reconfiguring CLI: $newEnvironments")
cli.configSsh(newEnvironments.map { it.name }.toSet())
}

Expand All @@ -141,10 +124,10 @@ class CoderRemoteProvider(

lastEnvironments = resolvedEnvironments
} catch (_: CancellationException) {
logger.debug("{} polling loop canceled", client.url)
context.logger.debug("${client.url} polling loop canceled")
break
} catch (ex: Exception) {
logger.info("setting exception $ex")
context.logger.info(ex, "workspace polling error encountered")
pollError = ex
logout()
break
Expand All @@ -171,15 +154,15 @@ class CoderRemoteProvider(
override fun getAccountDropDown(): DropDownMenu? {
val username = client?.me?.username
if (username != null) {
return dropDownFactory(i18n.pnotr(username), { logout() })
return dropDownFactory(context.i18n.pnotr(username), { logout() })
}
return null
}

override val additionalPluginActions: StateFlow<List<ActionDescription>> = MutableStateFlow(
listOf(
Action(i18n.ptrl("Settings")) {
ui.showUiPage(settingsPage)
Action(context.i18n.ptrl("Settings")) {
context.ui.showUiPage(settingsPage)
},
)
)
Expand Down Expand Up @@ -224,7 +207,7 @@ class CoderRemoteProvider(
* a form for creating new environments.
*/
override fun getNewEnvironmentUiPage(): UiPage =
NewEnvironmentPage(serviceLocator, i18n.pnotr(getDeploymentURL()?.first ?: ""))
NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))

/**
* We always show a list of environments.
Expand All @@ -244,10 +227,10 @@ class CoderRemoteProvider(
*/
override suspend fun handleUri(uri: URI) {
val params = uri.toQueryParameters()
coroutineScope.launch {
context.cs.launch {
val name = linkHandler.handle(params)
// TODO@JB: Now what? How do we actually connect this workspace?
logger.debug("External request for {}: {}", name, uri)
context.logger.debug("External request for $name: $uri")
}
}

Expand All @@ -260,7 +243,7 @@ class CoderRemoteProvider(
* than using multiple root pages.
*/
private fun goToEnvironmentsPage() {
serviceLocator.getService(EnvironmentUiPageManager::class.java).showPluginEnvironmentsPage()
context.envPageManager.showPluginEnvironmentsPage()
}

/**
Expand Down Expand Up @@ -290,18 +273,17 @@ class CoderRemoteProvider(

// Login flow.
val signInPage =
SignInPage(serviceLocator, i18n.ptrl("Sign In to Coder"), getDeploymentURL()) { deploymentURL ->
ui.showUiPage(
TokenPage(
serviceLocator,
i18n.ptrl("Enter your token"),
deploymentURL,
getToken(deploymentURL)
) { selectedToken ->
ui.showUiPage(createConnectPage(deploymentURL, selectedToken))
},
)
}
SignInPage(context, getDeploymentURL()) { deploymentURL ->
context.ui.showUiPage(
TokenPage(
context,
deploymentURL,
getToken(deploymentURL)
) { selectedToken ->
context.ui.showUiPage(createConnectPage(deploymentURL, selectedToken))
},
)
}

// We might have tried and failed to automatically log in.
autologinEx?.let { signInPage.notify("Error logging in", it) }
Expand All @@ -317,12 +299,11 @@ class CoderRemoteProvider(
* Create a connect page that starts polling and resets the UI on success.
*/
private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage(
serviceLocator,
context,
deploymentURL,
token,
settings,
httpClient,
i18n.ptrl("Connecting to Coder"),
::goToEnvironmentsPage,
) { client, cli ->
// Store the URL and token for use next time.
Expand Down
21 changes: 21 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.coder.toolbox

import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
import kotlinx.coroutines.CoroutineScope

data class CoderToolboxContext(
val ui: ToolboxUi,
val envPageManager: EnvironmentUiPageManager,
val envStateColorPalette: EnvironmentStateColorPalette,
val cs: CoroutineScope,
val logger: Logger,
val i18n: LocalizableStringFactory,
val settingsStore: PluginSettingsStore,
val secretsStore: PluginSecretStore
)
Loading
Loading