Skip to content

Commit 1f2cc4e

Browse files
authored
Preserve selection when workspace starts/stops (#233)
* Extract randWorkspace to shared class * Break out workspaces table I want to write a unit test for the selection logic. * 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.
1 parent d2a3649 commit 1f2cc4e

File tree

5 files changed

+145
-74
lines changed

5 files changed

+145
-74
lines changed

src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ import com.coder.gateway.sdk.v2.models.WorkspaceTransition
77
import java.util.UUID
88
import javax.swing.Icon
99

10+
// TODO: Refactor to have a list of workspaces that each have agents. We
11+
// present in the UI as a single flat list in the table (when there are no
12+
// agents we display a row for the workspace) but still, a list of workspaces
13+
// each with a list of agents might reflect reality more closely. When we
14+
// iterate over the list we can add the workspace row if it has no agents
15+
// otherwise iterate over the agents and then flatten the result.
1016
data class WorkspaceAgentModel(
1117
val workspaceID: UUID,
1218
val workspaceName: String,
13-
val name: String,
19+
val name: String, // Name of the workspace OR the agent if this is for an agent.
1420
val templateID: UUID,
1521
val templateName: String,
1622
val templateIconPath: String,

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
108108

109109
private var tfUrl: JTextField? = null
110110
private var cbExistingToken: JCheckBox? = null
111-
private var listTableModelOfWorkspaces = ListTableModel<WorkspaceAgentModel>(
112-
WorkspaceIconColumnInfo(""),
113-
WorkspaceNameColumnInfo("Name"),
114-
WorkspaceTemplateNameColumnInfo("Template"),
115-
WorkspaceVersionColumnInfo("Version"),
116-
WorkspaceStatusColumnInfo("Status")
117-
)
118111

119112
private val notificationBanner = NotificationBanner()
120-
private var tableOfWorkspaces = TableView(listTableModelOfWorkspaces).apply {
113+
private var tableOfWorkspaces = WorkspacesTable().apply {
121114
setEnableAntialiasing(true)
122115
rowSelectionAllowed = true
123116
columnSelectionAllowed = false
@@ -348,7 +341,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
348341
}
349342

350343
override fun onInit(wizardModel: CoderWorkspacesWizardModel) {
351-
listTableModelOfWorkspaces.items = emptyList()
344+
tableOfWorkspaces.listTableModel.items = emptyList()
352345
if (localWizardModel.coderURL.isNotBlank() && localWizardModel.token != null) {
353346
triggerWorkspacePolling(true)
354347
} else {
@@ -454,7 +447,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
454447
// Clear out old deployment details.
455448
poller?.cancel()
456449
tableOfWorkspaces.setEmptyState("Connecting to $deploymentURL...")
457-
listTableModelOfWorkspaces.items = emptyList()
450+
tableOfWorkspaces.listTableModel.items = emptyList()
458451

459452
// Authenticate and load in a background process with progress.
460453
// TODO: Make this cancelable.
@@ -676,11 +669,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
676669
}
677670
}
678671
withContext(Dispatchers.Main) {
679-
val selectedWorkspace = tableOfWorkspaces.selectedObject?.name
680-
listTableModelOfWorkspaces.items = ws.toList()
681-
if (selectedWorkspace != null) {
682-
tableOfWorkspaces.selectItem(selectedWorkspace)
683-
}
672+
val selectedWorkspace = tableOfWorkspaces.selectedObject
673+
tableOfWorkspaces.listTableModel.items = ws.toList()
674+
tableOfWorkspaces.selectItem(selectedWorkspace)
684675
}
685676
}
686677

@@ -764,7 +755,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
764755
else CoderCLIManager.getDataDir(),
765756
settings.binarySource,
766757
)
767-
cliManager.configSsh(listTableModelOfWorkspaces.items)
758+
cliManager.configSsh(tableOfWorkspaces.items)
768759

769760
logger.info("Opening IDE and Project Location window for ${workspace.name}")
770761
return true
@@ -776,6 +767,18 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
776767
cs.cancel()
777768
}
778769

770+
companion object {
771+
val logger = Logger.getInstance(CoderWorkspacesStepView::class.java.simpleName)
772+
}
773+
}
774+
775+
class WorkspacesTableModel : ListTableModel<WorkspaceAgentModel>(
776+
WorkspaceIconColumnInfo(""),
777+
WorkspaceNameColumnInfo("Name"),
778+
WorkspaceTemplateNameColumnInfo("Template"),
779+
WorkspaceVersionColumnInfo("Version"),
780+
WorkspaceStatusColumnInfo("Status")
781+
) {
779782
private class WorkspaceIconColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
780783
override fun valueOf(workspace: WorkspaceAgentModel?): String? {
781784
return workspace?.templateName
@@ -803,7 +806,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
803806
}
804807
}
805808

806-
private inner class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
809+
private class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
807810
override fun valueOf(workspace: WorkspaceAgentModel?): String? {
808811
return workspace?.name
809812
}
@@ -822,15 +825,16 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
822825
text = value
823826
}
824827

825-
font = RelativeFont.BOLD.derive(this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font)
828+
font = RelativeFont.BOLD.derive(table.tableHeader.font)
826829
border = JBUI.Borders.empty(0, 8)
827830
return this
828831
}
829832
}
830833
}
831834
}
832835

833-
private inner class WorkspaceTemplateNameColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
836+
private class WorkspaceTemplateNameColumnInfo(columnName: String) :
837+
ColumnInfo<WorkspaceAgentModel, String>(columnName) {
834838
override fun valueOf(workspace: WorkspaceAgentModel?): String? {
835839
return workspace?.templateName
836840
}
@@ -842,11 +846,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
842846
}
843847

844848
override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer {
845-
val simpleH3 = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font
846-
847-
val h3AttributesWithUnderlining = simpleH3.attributes as MutableMap<TextAttribute, Any>
848-
h3AttributesWithUnderlining[TextAttribute.UNDERLINE] = UNDERLINE_ON
849-
val underlinedH3 = JBFont.h3().deriveFont(h3AttributesWithUnderlining)
850849
return object : DefaultTableCellRenderer() {
851850
override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component {
852851
super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
@@ -855,10 +854,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
855854
}
856855
border = JBUI.Borders.empty(0, 8)
857856

857+
val simpleH3 = table.tableHeader.font
858858
if (table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) != null) {
859859
val mouseOverRow = table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) as Int
860860
if (mouseOverRow >= 0 && mouseOverRow == row) {
861-
font = underlinedH3
861+
val h3AttributesWithUnderlining = simpleH3.attributes as MutableMap<TextAttribute, Any>
862+
h3AttributesWithUnderlining[TextAttribute.UNDERLINE] = UNDERLINE_ON
863+
font = JBFont.h3().deriveFont(h3AttributesWithUnderlining)
862864
return this
863865
}
864866
}
@@ -869,7 +871,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
869871
}
870872
}
871873

872-
private inner class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
874+
private class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
873875
override fun valueOf(workspace: WorkspaceAgentModel?): String? {
874876
return workspace?.status?.label
875877
}
@@ -881,15 +883,15 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
881883
if (value is String) {
882884
text = value
883885
}
884-
font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font
886+
font = table.tableHeader.font
885887
border = JBUI.Borders.empty(0, 8)
886888
return this
887889
}
888890
}
889891
}
890892
}
891893

892-
private inner class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
894+
private class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentModel, String>(columnName) {
893895
override fun valueOf(workspace: WorkspaceAgentModel?): String? {
894896
return workspace?.agentStatus?.label
895897
}
@@ -902,41 +904,48 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
902904

903905
override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer {
904906
return object : DefaultTableCellRenderer() {
905-
override fun getTableCellRendererComponent(
906-
table: JTable,
907-
value: Any,
908-
isSelected: Boolean,
909-
hasFocus: Boolean,
910-
row: Int,
911-
column: Int,
912-
): Component {
907+
override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component {
913908
super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
914909
if (value is String) {
915910
text = value
916911
foreground = WorkspaceAndAgentStatus.from(value).statusColor()
917912
toolTipText = WorkspaceAndAgentStatus.from(value).description
918913
}
919-
font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font
914+
font = table.tableHeader.font
920915
border = JBUI.Borders.empty(0, 8)
921916
return this
922917
}
923918
}
924919
}
925920
}
921+
}
926922

927-
private fun TableView<WorkspaceAgentModel>.selectItem(workspaceName: String?) {
928-
if (workspaceName != null) {
929-
this.items.forEachIndexed { index, workspaceAgentModel ->
930-
if (workspaceAgentModel.name == workspaceName) {
931-
selectionModel.addSelectionInterval(convertRowIndexToView(index), convertRowIndexToView(index))
932-
// fix cell selection case
933-
columnModel.selectionModel.addSelectionInterval(0, columnCount - 1)
934-
}
935-
}
923+
class WorkspacesTable : TableView<WorkspaceAgentModel>(WorkspacesTableModel()) {
924+
/**
925+
* Given either a workspace or an agent select in order of preference:
926+
* 1. That same agent or workspace.
927+
* 2. The first match for the workspace (workspace itself or first agent).
928+
*/
929+
fun selectItem(workspace: WorkspaceAgentModel?) {
930+
val index = getNewSelection(workspace)
931+
if (index > -1) {
932+
selectionModel.addSelectionInterval(convertRowIndexToView(index), convertRowIndexToView(index))
933+
// Fix cell selection case.
934+
columnModel.selectionModel.addSelectionInterval(0, columnCount - 1)
936935
}
937936
}
938937

939-
companion object {
940-
val logger = Logger.getInstance(CoderWorkspacesStepView::class.java.simpleName)
938+
private fun getNewSelection(oldSelection: WorkspaceAgentModel?): Int {
939+
if (oldSelection == null) {
940+
return -1
941+
}
942+
val index = listTableModel.items.indexOfFirst {
943+
it.name == oldSelection.name && it.workspaceName == oldSelection.workspaceName
944+
}
945+
if (index > -1) {
946+
return index
947+
}
948+
return listTableModel.items.indexOfFirst { it.workspaceName == oldSelection.workspaceName }
941949
}
950+
942951
}

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
package com.coder.gateway.sdk
22

3-
import com.coder.gateway.models.WorkspaceAgentModel
4-
import com.coder.gateway.models.WorkspaceAndAgentStatus
5-
import com.coder.gateway.models.WorkspaceVersionStatus
6-
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
7-
import com.coder.gateway.sdk.v2.models.WorkspaceTransition
83
import com.sun.net.httpserver.HttpExchange
94
import com.sun.net.httpserver.HttpHandler
105
import com.sun.net.httpserver.HttpServer
@@ -364,25 +359,6 @@ class CoderCLIManagerTest extends Specification {
364359
Path.of("/tmp/coder-gateway-test/localappdata/coder-gateway") == dataDir()
365360
}
366361

367-
private WorkspaceAgentModel randWorkspace(String name) {
368-
return new WorkspaceAgentModel(
369-
UUID.randomUUID(),
370-
name,
371-
name,
372-
UUID.randomUUID(),
373-
"template-name",
374-
"template-icon-path",
375-
null,
376-
WorkspaceVersionStatus.UPDATED,
377-
WorkspaceStatus.RUNNING,
378-
WorkspaceAndAgentStatus.READY,
379-
WorkspaceTransition.START,
380-
null,
381-
null,
382-
null
383-
)
384-
}
385-
386362
def "configures an SSH file"() {
387363
given:
388364
def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf")
@@ -401,7 +377,7 @@ class CoderCLIManagerTest extends Specification {
401377
.replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", ccm.localBinaryPath.toString())
402378

403379
when:
404-
ccm.configSsh(workspaces.collect { randWorkspace(it) })
380+
ccm.configSsh(workspaces.collect { DataGen.workspace(it) })
405381

406382
then:
407383
sshConfigPath.toFile().text == expectedConf
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import com.coder.gateway.views.steps.WorkspacesTable
2+
import spock.lang.Specification
3+
import spock.lang.Unroll
4+
5+
@Unroll
6+
class CoderWorkspacesStepViewTest extends Specification {
7+
def "gets new selection"() {
8+
given:
9+
def table = new WorkspacesTable()
10+
table.listTableModel.items = List.of(
11+
// An off workspace.
12+
DataGen.workspace("ws1", "ws1"),
13+
14+
// On workspaces.
15+
DataGen.workspace("agent1", "ws2"),
16+
DataGen.workspace("agent2", "ws2"),
17+
DataGen.workspace("agent3", "ws3"),
18+
19+
// Another off workspace.
20+
DataGen.workspace("ws4", "ws4"),
21+
22+
// In practice we do not list both agents and workspaces
23+
// together but here test that anyway with an agent first and
24+
// then with a workspace first.
25+
DataGen.workspace("agent2", "ws5"),
26+
DataGen.workspace("ws5", "ws5"),
27+
DataGen.workspace("ws6", "ws6"),
28+
DataGen.workspace("agent3", "ws6"),
29+
)
30+
31+
expect:
32+
table.getNewSelection(selected) == expected
33+
34+
where:
35+
selected | expected
36+
null | -1 // No selection.
37+
DataGen.workspace("gone", "gone") | -1 // No workspace that matches.
38+
DataGen.workspace("ws1", "ws1") | 0 // Workspace exact match.
39+
DataGen.workspace("gone", "ws1") | 0 // Agent gone, select workspace.
40+
DataGen.workspace("ws2", "ws2") | 1 // Workspace gone, select first agent.
41+
DataGen.workspace("agent1", "ws2") | 1 // Agent exact match.
42+
DataGen.workspace("agent2", "ws2") | 2 // Agent exact match.
43+
DataGen.workspace("ws3", "ws3") | 3 // Workspace gone, select first agent.
44+
DataGen.workspace("agent3", "ws3") | 3 // Agent exact match.
45+
DataGen.workspace("gone", "ws4") | 4 // Agent gone, select workspace.
46+
DataGen.workspace("ws4", "ws4") | 4 // Workspace exact match.
47+
DataGen.workspace("agent2", "ws5") | 5 // Agent exact match.
48+
DataGen.workspace("gone", "ws5") | 5 // Agent gone, another agent comes first.
49+
DataGen.workspace("ws5", "ws5") | 6 // Workspace exact match.
50+
DataGen.workspace("ws6", "ws6") | 7 // Workspace exact match.
51+
DataGen.workspace("gone", "ws6") | 7 // Agent gone, workspace comes first.
52+
DataGen.workspace("agent3", "ws6") | 8 // Agent exact match.
53+
}
54+
}

src/test/groovy/DataGen.groovy

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import com.coder.gateway.models.WorkspaceAgentModel
2+
import com.coder.gateway.models.WorkspaceAndAgentStatus
3+
import com.coder.gateway.models.WorkspaceVersionStatus
4+
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
5+
import com.coder.gateway.sdk.v2.models.WorkspaceTransition
6+
7+
class DataGen {
8+
static WorkspaceAgentModel workspace(String name, String workspaceName = name) {
9+
return new WorkspaceAgentModel(
10+
UUID.randomUUID(),
11+
workspaceName,
12+
name,
13+
UUID.randomUUID(),
14+
"template-name",
15+
"template-icon-path",
16+
null,
17+
WorkspaceVersionStatus.UPDATED,
18+
WorkspaceStatus.RUNNING,
19+
WorkspaceAndAgentStatus.READY,
20+
WorkspaceTransition.START,
21+
null,
22+
null,
23+
null
24+
)
25+
}
26+
}

0 commit comments

Comments
 (0)