From c138d0968613ecc27a92bd58507f848914f435db Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 10:07:26 -0800 Subject: [PATCH 1/6] Extract randWorkspace to shared class --- src/test/groovy/CoderCLIManagerTest.groovy | 26 +--------------------- src/test/groovy/DataGen.groovy | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 25 deletions(-) create mode 100644 src/test/groovy/DataGen.groovy diff --git a/src/test/groovy/CoderCLIManagerTest.groovy b/src/test/groovy/CoderCLIManagerTest.groovy index 8f09d4c7..9c135c88 100644 --- a/src/test/groovy/CoderCLIManagerTest.groovy +++ b/src/test/groovy/CoderCLIManagerTest.groovy @@ -1,10 +1,5 @@ package com.coder.gateway.sdk -import com.coder.gateway.models.WorkspaceAgentModel -import com.coder.gateway.models.WorkspaceAndAgentStatus -import com.coder.gateway.models.WorkspaceVersionStatus -import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.coder.gateway.sdk.v2.models.WorkspaceTransition import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer @@ -364,25 +359,6 @@ class CoderCLIManagerTest extends Specification { Path.of("/tmp/coder-gateway-test/localappdata/coder-gateway") == dataDir() } - private WorkspaceAgentModel randWorkspace(String name) { - return new WorkspaceAgentModel( - UUID.randomUUID(), - name, - name, - UUID.randomUUID(), - "template-name", - "template-icon-path", - null, - WorkspaceVersionStatus.UPDATED, - WorkspaceStatus.RUNNING, - WorkspaceAndAgentStatus.READY, - WorkspaceTransition.START, - null, - null, - null - ) - } - def "configures an SSH file"() { given: def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf") @@ -401,7 +377,7 @@ class CoderCLIManagerTest extends Specification { .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", ccm.localBinaryPath.toString()) when: - ccm.configSsh(workspaces.collect { randWorkspace(it) }) + ccm.configSsh(workspaces.collect { DataGen.workspace(it) }) then: sshConfigPath.toFile().text == expectedConf diff --git a/src/test/groovy/DataGen.groovy b/src/test/groovy/DataGen.groovy new file mode 100644 index 00000000..af609f99 --- /dev/null +++ b/src/test/groovy/DataGen.groovy @@ -0,0 +1,26 @@ +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.models.WorkspaceAndAgentStatus +import com.coder.gateway.models.WorkspaceVersionStatus +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.sdk.v2.models.WorkspaceTransition + +class DataGen { + static WorkspaceAgentModel workspace(String name, String workspaceName = name) { + return new WorkspaceAgentModel( + UUID.randomUUID(), + workspaceName, + name, + UUID.randomUUID(), + "template-name", + "template-icon-path", + null, + WorkspaceVersionStatus.UPDATED, + WorkspaceStatus.RUNNING, + WorkspaceAndAgentStatus.READY, + WorkspaceTransition.START, + null, + null, + null + ) + } +} From f9c8573979de390b9a8a058f6e23418d41b6ebe6 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 10:59:10 -0800 Subject: [PATCH 2/6] Break out workspaces table I want to write a unit test for the selection logic. --- .../views/steps/CoderWorkspacesStepView.kt | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index a0fa03df..11ab21b7 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -108,16 +108,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private var tfUrl: JTextField? = null private var cbExistingToken: JCheckBox? = null - private var listTableModelOfWorkspaces = ListTableModel( - WorkspaceIconColumnInfo(""), - WorkspaceNameColumnInfo("Name"), - WorkspaceTemplateNameColumnInfo("Template"), - WorkspaceVersionColumnInfo("Version"), - WorkspaceStatusColumnInfo("Status") - ) private val notificationBanner = NotificationBanner() - private var tableOfWorkspaces = TableView(listTableModelOfWorkspaces).apply { + private var tableOfWorkspaces = WorkspacesTable().apply { setEnableAntialiasing(true) rowSelectionAllowed = true columnSelectionAllowed = false @@ -348,7 +341,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } override fun onInit(wizardModel: CoderWorkspacesWizardModel) { - listTableModelOfWorkspaces.items = emptyList() + tableOfWorkspaces.listTableModel.items = emptyList() if (localWizardModel.coderURL.isNotBlank() && localWizardModel.token != null) { triggerWorkspacePolling(true) } else { @@ -454,7 +447,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod // Clear out old deployment details. poller?.cancel() tableOfWorkspaces.setEmptyState("Connecting to $deploymentURL...") - listTableModelOfWorkspaces.items = emptyList() + tableOfWorkspaces.listTableModel.items = emptyList() // Authenticate and load in a background process with progress. // TODO: Make this cancelable. @@ -677,7 +670,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } withContext(Dispatchers.Main) { val selectedWorkspace = tableOfWorkspaces.selectedObject?.name - listTableModelOfWorkspaces.items = ws.toList() + tableOfWorkspaces.listTableModel.items = ws.toList() if (selectedWorkspace != null) { tableOfWorkspaces.selectItem(selectedWorkspace) } @@ -764,7 +757,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod else CoderCLIManager.getDataDir(), settings.binarySource, ) - cliManager.configSsh(listTableModelOfWorkspaces.items) + cliManager.configSsh(tableOfWorkspaces.items) logger.info("Opening IDE and Project Location window for ${workspace.name}") return true @@ -776,6 +769,18 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.cancel() } + companion object { + val logger = Logger.getInstance(CoderWorkspacesStepView::class.java.simpleName) + } +} + +class WorkspacesTableModel : ListTableModel( + WorkspaceIconColumnInfo(""), + WorkspaceNameColumnInfo("Name"), + WorkspaceTemplateNameColumnInfo("Template"), + WorkspaceVersionColumnInfo("Version"), + WorkspaceStatusColumnInfo("Status") +) { private class WorkspaceIconColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.templateName @@ -803,7 +808,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private inner class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo(columnName) { + private class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.name } @@ -822,7 +827,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod text = value } - font = RelativeFont.BOLD.derive(this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font) + font = RelativeFont.BOLD.derive(table.tableHeader.font) border = JBUI.Borders.empty(0, 8) return this } @@ -830,7 +835,8 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private inner class WorkspaceTemplateNameColumnInfo(columnName: String) : ColumnInfo(columnName) { + private class WorkspaceTemplateNameColumnInfo(columnName: String) : + ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.templateName } @@ -842,11 +848,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { - val simpleH3 = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font - - val h3AttributesWithUnderlining = simpleH3.attributes as MutableMap - h3AttributesWithUnderlining[TextAttribute.UNDERLINE] = UNDERLINE_ON - val underlinedH3 = JBFont.h3().deriveFont(h3AttributesWithUnderlining) return object : DefaultTableCellRenderer() { override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) @@ -855,10 +856,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } border = JBUI.Borders.empty(0, 8) + val simpleH3 = table.tableHeader.font if (table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) != null) { val mouseOverRow = table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) as Int if (mouseOverRow >= 0 && mouseOverRow == row) { - font = underlinedH3 + val h3AttributesWithUnderlining = simpleH3.attributes as MutableMap + h3AttributesWithUnderlining[TextAttribute.UNDERLINE] = UNDERLINE_ON + font = JBFont.h3().deriveFont(h3AttributesWithUnderlining) return this } } @@ -869,7 +873,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private inner class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo(columnName) { + private class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.status?.label } @@ -881,7 +885,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod if (value is String) { text = value } - font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font + font = table.tableHeader.font border = JBUI.Borders.empty(0, 8) return this } @@ -889,7 +893,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private inner class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo(columnName) { + private class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.agentStatus?.label } @@ -902,29 +906,24 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { return object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent( - table: JTable, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int, - ): Component { + override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) if (value is String) { text = value foreground = WorkspaceAndAgentStatus.from(value).statusColor() toolTipText = WorkspaceAndAgentStatus.from(value).description } - font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font + font = table.tableHeader.font border = JBUI.Borders.empty(0, 8) return this } } } } +} - private fun TableView.selectItem(workspaceName: String?) { +class WorkspacesTable : TableView(WorkspacesTableModel()) { + fun selectItem(workspaceName: String?) { if (workspaceName != null) { this.items.forEachIndexed { index, workspaceAgentModel -> if (workspaceAgentModel.name == workspaceName) { @@ -935,8 +934,4 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } } - - companion object { - val logger = Logger.getInstance(CoderWorkspacesStepView::class.java.simpleName) - } } From a83b0bc0329000f94ee96e5f2bf73ac66fa59fe2 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 11:24:19 -0800 Subject: [PATCH 3/6] Preserve selection when workspaces starts or stops When a workspace starts the workspace row is replaced with a row for the agent(s) and you have to select it again which is annoying (similarly for when the workspace stops). This preserves the selection when the workspace transitions. --- .../gateway/models/WorkspaceAgentModel.kt | 8 ++- .../views/steps/CoderWorkspacesStepView.kt | 40 +++++++++----- .../groovy/CoderWorkspacesStepViewTest.groovy | 54 +++++++++++++++++++ 3 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 src/test/groovy/CoderWorkspacesStepViewTest.groovy diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt index 6cfc103b..c4893b9c 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt @@ -7,10 +7,16 @@ import com.coder.gateway.sdk.v2.models.WorkspaceTransition import java.util.UUID import javax.swing.Icon +// TODO: Refactor to have a list of workspaces that each have agents. We +// present in the UI as a single flat list in the table (when there are no +// agents we display a row for the workspace) but still, a list of workspaces +// each with a list of agents might reflect reality more closely. When we +// iterate over the list we can add the workspace row if it has no agents +// otherwise iterate over the agents and then flatten the result. data class WorkspaceAgentModel( val workspaceID: UUID, val workspaceName: String, - val name: String, + val name: String, // Name of the workspace OR the agent if this is for an agent. val templateID: UUID, val templateName: String, val templateIconPath: String, diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 11ab21b7..62d4c1d3 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -669,11 +669,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } withContext(Dispatchers.Main) { - val selectedWorkspace = tableOfWorkspaces.selectedObject?.name + val selectedWorkspace = tableOfWorkspaces.selectedObject tableOfWorkspaces.listTableModel.items = ws.toList() - if (selectedWorkspace != null) { - tableOfWorkspaces.selectItem(selectedWorkspace) - } + tableOfWorkspaces.selectItem(selectedWorkspace) } } @@ -923,15 +921,31 @@ class WorkspacesTableModel : ListTableModel( } class WorkspacesTable : TableView(WorkspacesTableModel()) { - fun selectItem(workspaceName: String?) { - if (workspaceName != null) { - this.items.forEachIndexed { index, workspaceAgentModel -> - if (workspaceAgentModel.name == workspaceName) { - selectionModel.addSelectionInterval(convertRowIndexToView(index), convertRowIndexToView(index)) - // fix cell selection case - columnModel.selectionModel.addSelectionInterval(0, columnCount - 1) - } - } + /** + * Given either a workspace or an agent select in order of preference: + * 1. That same agent or workspace. + * 2. The first match for the workspace (workspace itself or first agent). + */ + fun selectItem(workspace: WorkspaceAgentModel?) { + val index = getNewSelection(workspace) + if (index > -1) { + selectionModel.addSelectionInterval(convertRowIndexToView(index), convertRowIndexToView(index)) + // Fix cell selection case. + columnModel.selectionModel.addSelectionInterval(0, columnCount - 1) } } + + private fun getNewSelection(oldSelection: WorkspaceAgentModel?): Int { + if (oldSelection == null) { + return -1 + } + val index = listTableModel.items.indexOfFirst { + it.name == oldSelection.name && it.workspaceName == oldSelection.workspaceName + } + if (index > -1) { + return index + } + return listTableModel.items.indexOfFirst { it.workspaceName == oldSelection.workspaceName } + } + } diff --git a/src/test/groovy/CoderWorkspacesStepViewTest.groovy b/src/test/groovy/CoderWorkspacesStepViewTest.groovy new file mode 100644 index 00000000..c684e963 --- /dev/null +++ b/src/test/groovy/CoderWorkspacesStepViewTest.groovy @@ -0,0 +1,54 @@ +import com.coder.gateway.views.steps.WorkspacesTable +import spock.lang.Specification +import spock.lang.Unroll + +@Unroll +class CoderWorkspacesStepViewTest extends Specification { + def "gets new selection"() { + given: + def table = new WorkspacesTable() + table.listTableModel.items = List.of( + // An off workspace. + DataGen.workspace("ws1", "ws1"), + + // On workspaces. + DataGen.workspace("agent1", "ws2"), + DataGen.workspace("agent2", "ws2"), + DataGen.workspace("agent3", "ws3"), + + // Another off workspace. + DataGen.workspace("ws4", "ws4"), + + // In practice we do not list both agents and workspaces + // together but here test that anyway with an agent first and + // then with a workspace first. + DataGen.workspace("agent2", "ws5"), + DataGen.workspace("ws5", "ws5"), + DataGen.workspace("ws6", "ws6"), + DataGen.workspace("agent3", "ws6"), + ) + + expect: + table.getNewSelection(selected) == expected + + where: + selected | expected + null | -1 // No selection. + DataGen.workspace("gone", "gone") | -1 // No workspace that matches. + DataGen.workspace("ws1", "ws1") | 0 // Workspace exact match. + DataGen.workspace("gone", "ws1") | 0 // Agent gone, select workspace. + DataGen.workspace("ws2", "ws2") | 1 // Workspace gone, select first agent. + DataGen.workspace("agent1", "ws2") | 1 // Agent exact match. + DataGen.workspace("agent2", "ws2") | 2 // Agent exact match. + DataGen.workspace("ws3", "ws3") | 3 // Workspace gone, select first agent. + DataGen.workspace("agent3", "ws3") | 3 // Agent exact match. + DataGen.workspace("gone", "ws4") | 4 // Agent gone, select workspace. + DataGen.workspace("ws4", "ws4") | 4 // Workspace exact match. + DataGen.workspace("agent2", "ws5") | 5 // Agent exact match. + DataGen.workspace("gone", "ws5") | 5 // Agent gone, another agent comes first. + DataGen.workspace("ws5", "ws5") | 6 // Workspace exact match. + DataGen.workspace("ws6", "ws6") | 7 // Workspace exact match. + DataGen.workspace("gone", "ws6") | 7 // Agent gone, workspace comes first. + DataGen.workspace("agent3", "ws6") | 8 // Agent exact match. + } +} From ee8e7ff8a48ff3cd8f5830cd2f3a3663345e17b8 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 12:05:27 -0800 Subject: [PATCH 4/6] Surface error when listing editors Also my IDE tells me `is TimeoutCancellationException` is unreachable so I removed it. --- .../steps/CoderLocateRemoteProjectStepView.kt | 30 +++++++------------ .../messages/CoderGatewayBundle.properties | 3 +- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 420c7eac..7d8b47d6 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -27,7 +27,6 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.remote.AuthType import com.intellij.remote.RemoteCredentialsHolder -import com.intellij.ssh.SshException import com.intellij.ui.AnimatedIcon import com.intellij.ui.ColoredListCellRenderer import com.intellij.ui.DocumentAdapter @@ -57,7 +56,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin @@ -107,6 +105,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea setNextButtonEnabled(this.selectedItem != null) ApplicationManager.getApplication().invokeLater { logger.info("Selected IDE: ${this.selectedItem}") + cbIDEComment.foreground = UIUtil.getContextHelpForeground() when (this.selectedItem?.status) { IdeStatus.ALREADY_INSTALLED -> cbIDEComment.text = @@ -156,6 +155,10 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea override val nextActionText = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text") override fun onInit(wizardModel: CoderWorkspacesWizardModel) { + // Clear error message as it might still be displaying. + cbIDEComment.foreground = UIUtil.getContextHelpForeground() + cbIDEComment.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment") + cbIDE.renderer = IDECellRenderer() ideComboBoxModel.removeAllElements() val deploymentURL = wizardModel.coderURL.toURL() @@ -183,30 +186,17 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea when (e) { is InterruptedException -> Unit is CancellationException -> Unit - is TimeoutCancellationException, - is SshException -> { - logger.error("Can't connect to workspace ${selectedWorkspace.name}. Reason: $e") - withContext(Dispatchers.Main) { - setNextButtonEnabled(false) - cbIDE.renderer = object : ColoredListCellRenderer() { - override fun customizeCellRenderer(list: JList, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) { - background = UIUtil.getListBackground(isSelected, cellHasFocus) - icon = UIUtil.getBalloonErrorIcon() - append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ssh.error.text")) - } - } - } - } - else -> { - logger.error("Could not resolve any IDE for workspace ${selectedWorkspace.name}. Reason: $e") + logger.error("Failed to retrieve IDEs for workspace ${selectedWorkspace.name}", e) withContext(Dispatchers.Main) { setNextButtonEnabled(false) + cbIDEComment.foreground = UIUtil.getErrorForeground() + cbIDEComment.text = e.message ?: "The error did not provide any further details" cbIDE.renderer = object : ColoredListCellRenderer() { override fun customizeCellRenderer(list: JList, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) { background = UIUtil.getListBackground(isSelected, cellHasFocus) icon = UIUtil.getBalloonErrorIcon() - append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.error.text")) + append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text")) } } } @@ -217,7 +207,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea } private fun installRemotePathValidator(executor: HighLevelHostAccessor) { - var disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderLocateRemoteProjectStepView.javaClass.name) + val disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderLocateRemoteProjectStepView::class.java.name) ComponentValidator(disposable).installOn(tfProject) tfProject.document.addDocumentListener(object : DocumentAdapter() { diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index fa45415f..a89b98a8 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -29,8 +29,7 @@ gateway.connector.view.workspaces.token.rejected=This token was rejected. gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config. gateway.connector.view.workspaces.token.none=No existing token found. gateway.connector.view.coder.remoteproject.loading.text=Retrieving products... -gateway.connector.view.coder.remoteproject.ide.error.text=Could not retrieve any IDE because an error was encountered. Please check the logs for more details! -gateway.connector.view.coder.remoteproject.ssh.error.text=Can't connect to the workspace. Please make sure Coder Agent is running! +gateway.connector.view.coder.remoteproject.error.text=Failed to retrieve IDEs gateway.connector.view.coder.remoteproject.next.text=Start IDE and connect gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project for workspace {0} gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded from jetbrains.com and installed to the default path on the remote host. From 0e5281856bb8574fe2d4aaac8c524882cf80b937 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 14:31:59 -0800 Subject: [PATCH 5/6] Add retry to editor selection --- .../kotlin/com/coder/gateway/sdk/Retry.kt | 46 ++++++++ .../steps/CoderLocateRemoteProjectStepView.kt | 106 ++++++++++-------- .../messages/CoderGatewayBundle.properties | 2 + 3 files changed, 108 insertions(+), 46 deletions(-) create mode 100644 src/main/kotlin/com/coder/gateway/sdk/Retry.kt diff --git a/src/main/kotlin/com/coder/gateway/sdk/Retry.kt b/src/main/kotlin/com/coder/gateway/sdk/Retry.kt new file mode 100644 index 00000000..23e6e650 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/Retry.kt @@ -0,0 +1,46 @@ +package com.coder.gateway.sdk + +import kotlinx.coroutines.delay +import kotlinx.datetime.Clock +import java.util.Random +import java.util.concurrent.TimeUnit +import kotlin.concurrent.timer +import kotlin.math.max +import kotlin.math.min + +/** + * Similar to Intellij's except it gives you the next delay, does not do its own + * logging, updates periodically (for counting down), and runs forever. + */ +suspend fun suspendingRetryWithExponentialBackOff( + initialDelayMs: Long = TimeUnit.SECONDS.toMillis(5), + backOffLimitMs: Long = TimeUnit.MINUTES.toMillis(3), + backOffFactor: Int = 2, + backOffJitter: Double = 0.1, + update: (attempt: Int, remainingMs: Long, e: Exception) -> Unit, + action: suspend (attempt: Int) -> T +): T { + val random = Random() + var delayMs = initialDelayMs + for (attempt in 1..Int.MAX_VALUE) { + try { + return action(attempt) + } + catch (e: Exception) { + val end = Clock.System.now().toEpochMilliseconds() + delayMs + val timer = timer(period = TimeUnit.SECONDS.toMillis(1)) { + val now = Clock.System.now().toEpochMilliseconds() + val next = max(end - now, 0) + if (next > 0) { + update(attempt, next, e) + } else { + this.cancel() + } + } + delay(delayMs) + timer.cancel() + delayMs = min(delayMs * backOffFactor, backOffLimitMs) + (random.nextGaussian() * delayMs * backOffJitter).toLong() + } + } + error("Should never be reached") +} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 7d8b47d6..eaaf249a 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -8,6 +8,7 @@ import com.coder.gateway.sdk.Arch import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClientService import com.coder.gateway.sdk.OS +import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.withPath import com.coder.gateway.toWorkspaceParams @@ -51,8 +52,8 @@ import com.jetbrains.gateway.ssh.HighLevelHostAccessor import com.jetbrains.gateway.ssh.IdeStatus import com.jetbrains.gateway.ssh.IdeWithStatus import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.deploy.DeployException import com.jetbrains.gateway.ssh.util.validateRemotePath -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -61,20 +62,24 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.time.withTimeout import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.connection.ConnectionException import java.awt.Component import java.awt.FlowLayout -import java.time.Duration import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import javax.swing.ComboBoxModel import javax.swing.DefaultComboBoxModel +import javax.swing.Icon import javax.swing.JLabel import javax.swing.JList import javax.swing.JPanel import javax.swing.ListCellRenderer import javax.swing.SwingConstants import javax.swing.event.DocumentEvent +import kotlin.coroutines.cancellation.CancellationException class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable { private val cs = CoroutineScope(Dispatchers.Main) @@ -100,7 +105,6 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea row { label("IDE:") cbIDE = cell(IDEComboBox(ideComboBoxModel).apply { - renderer = IDECellRenderer() addActionListener { setNextButtonEnabled(this.selectedItem != null) ApplicationManager.getApplication().invokeLater { @@ -148,19 +152,19 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea gap(RightGap.SMALL) }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(0, 16, 0, 16) + border = JBUI.Borders.empty(0, 16) } override val previousActionText = IdeBundle.message("button.back") override val nextActionText = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text") override fun onInit(wizardModel: CoderWorkspacesWizardModel) { - // Clear error message as it might still be displaying. + // Clear contents from the last attempt if any. cbIDEComment.foreground = UIUtil.getContextHelpForeground() cbIDEComment.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment") - - cbIDE.renderer = IDECellRenderer() ideComboBoxModel.removeAllElements() + setNextButtonEnabled(false) + val deploymentURL = wizardModel.coderURL.toURL() val selectedWorkspace = wizardModel.selectedWorkspace if (selectedWorkspace == null) { @@ -174,33 +178,53 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea terminalLink.url = coderClient.coderURL.withPath("/@${coderClient.me.username}/${selectedWorkspace.name}/terminal").toString() ideResolvingJob = cs.launch { - try { - val executor = withTimeout(Duration.ofSeconds(60)) { - createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) - } - retrieveIDES(executor, selectedWorkspace) - if (ComponentValidator.getInstance(tfProject).isEmpty) { - installRemotePathValidator(executor) - } - } catch (e: Exception) { - when (e) { - is InterruptedException -> Unit - is CancellationException -> Unit - else -> { - logger.error("Failed to retrieve IDEs for workspace ${selectedWorkspace.name}", e) - withContext(Dispatchers.Main) { - setNextButtonEnabled(false) - cbIDEComment.foreground = UIUtil.getErrorForeground() - cbIDEComment.text = e.message ?: "The error did not provide any further details" - cbIDE.renderer = object : ColoredListCellRenderer() { - override fun customizeCellRenderer(list: JList, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) { - background = UIUtil.getListBackground(isSelected, cellHasFocus) - icon = UIUtil.getBalloonErrorIcon() - append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text")) + val ides = suspendingRetryWithExponentialBackOff( + action={ attempt -> + // Reset text in the select dropdown. + withContext(Dispatchers.Main) { + cbIDE.renderer = IDECellRenderer( + if (attempt > 1) CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry.text", attempt) + else CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text")) + } + try { + val executor = createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) + if (ComponentValidator.getInstance(tfProject).isEmpty) { + installRemotePathValidator(executor) + } + retrieveIDEs(executor, selectedWorkspace) + } catch (e: Exception) { + when(e) { + is InterruptedException -> Unit + is CancellationException -> Unit + // Throw to retry these. The main one is + // DeployException which fires when dd times out. + is ConnectionException, is TimeoutException, + is SSHException, is DeployException -> throw e + else -> { + withContext(Dispatchers.Main) { + logger.error("Failed to retrieve IDEs (attempt $attempt)", e) + cbIDEComment.foreground = UIUtil.getErrorForeground() + cbIDEComment.text = e.message ?: "The error did not provide any further details" + cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text"), UIUtil.getBalloonErrorIcon()) } } } + null } + }, + update = { attempt, retryMs, e -> + logger.error("Failed to retrieve IDEs (attempt $attempt; will retry in $retryMs ms)", e) + cbIDEComment.foreground = UIUtil.getErrorForeground() + cbIDEComment.text = e.message ?: "The error did not provide any further details" + val delayS = TimeUnit.MILLISECONDS.toSeconds(retryMs) + val delay = if (delayS < 1) "now" else "in $delayS second${if (delayS > 1) "s" else ""}" + cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry-error.text", delay)) + }, + ) + if (ides != null) { + withContext(Dispatchers.Main) { + ideComboBoxModel.addAll(ides) + cbIDE.selectedIndex = 0 } } } @@ -248,7 +272,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea ) } - private suspend fun retrieveIDES(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel) { + private suspend fun retrieveIDEs(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel): List { logger.info("Retrieving available IDE's for ${selectedWorkspace.name} workspace...") val workspaceOS = if (selectedWorkspace.agentOS != null && selectedWorkspace.agentArch != null) toDeployedOS(selectedWorkspace.agentOS, selectedWorkspace.agentArch) else withContext(Dispatchers.IO) { executor.guessOs() @@ -269,21 +293,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea val idesWithStatus = idesWithStatusJob.await() if (installedIdes.isEmpty()) { logger.info("No IDE is installed in workspace ${selectedWorkspace.name}") - } else { - withContext(Dispatchers.Main) { - 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 { - withContext(Dispatchers.Main) { - ideComboBoxModel.addAll(idesWithStatus) - cbIDE.selectedIndex = 0 - } } + return installedIdes + idesWithStatus } private fun toDeployedOS(os: OS, arch: Arch): DeployTargetOS { @@ -353,12 +367,12 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea } } - private class IDECellRenderer : ListCellRenderer { + private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : ListCellRenderer { private val loadingComponentRenderer: ListCellRenderer = object : ColoredListCellRenderer() { override fun customizeCellRenderer(list: JList, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) { background = UIUtil.getListBackground(isSelected, cellHasFocus) - icon = AnimatedIcon.Default.INSTANCE - append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text")) + icon = cellIcon + append(message) } } diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index a89b98a8..d8295e5c 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -29,7 +29,9 @@ gateway.connector.view.workspaces.token.rejected=This token was rejected. gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config. gateway.connector.view.workspaces.token.none=No existing token found. gateway.connector.view.coder.remoteproject.loading.text=Retrieving products... +gateway.connector.view.coder.remoteproject.retry.text=Retrieving products (attempt {0})... gateway.connector.view.coder.remoteproject.error.text=Failed to retrieve IDEs +gateway.connector.view.coder.remoteproject.retry-error.text=Failed to retrieve IDEs...retrying {0} gateway.connector.view.coder.remoteproject.next.text=Start IDE and connect gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project for workspace {0} gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded from jetbrains.com and installed to the default path on the remote host. From fa7c38356a8dfa34fb92dad0739e6e68ea55d218 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 26 Apr 2023 15:58:28 -0800 Subject: [PATCH 6/6] Maybe fix visual jitter --- .../gateway/views/steps/CoderLocateRemoteProjectStepView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index eaaf249a..3154bbd4 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -134,7 +134,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment"), false, -1, true ) - ).component + ).resizableColumn().align(AlignX.FILL).component }.topGap(TopGap.NONE).bottomGap(BottomGap.NONE).layout(RowLayout.PARENT_GRID) row { label("Project directory:")