Skip to content

Commit fca838d

Browse files
committed
Impl: basic recent workspace connections view
- lists workspace connections grouped by hostname - action links (that don't work yet) to re-open a session - persist recent connections between Gateway restarts - service to manage the recent connections (add/remove/serialize/deserialize)
1 parent 6ce12b3 commit fca838d

9 files changed

+304
-17
lines changed

src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt

+38-16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.coder.gateway
22

3+
import com.coder.gateway.models.RecentWorkspaceConnection
4+
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
35
import com.coder.gateway.views.CoderGatewayConnectionComponent
6+
import com.intellij.openapi.components.service
47
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
58
import com.intellij.remote.AuthType
69
import com.intellij.remote.RemoteCredentialsHolder
@@ -15,61 +18,80 @@ import com.jetbrains.gateway.ssh.SshDeployFlowUtil
1518
import com.jetbrains.gateway.ssh.SshDownloadMethod
1619
import com.jetbrains.gateway.ssh.SshMultistagePanelContext
1720
import com.jetbrains.rd.util.lifetime.LifetimeDefinition
18-
import kotlinx.coroutines.async
21+
import kotlinx.coroutines.launch
1922
import java.time.Duration
23+
import java.time.LocalDateTime
24+
import java.time.format.DateTimeFormatter
2025
import java.util.logging.Logger
2126
import javax.swing.JComponent
2227

2328
class CoderGatewayConnectionProvider : GatewayConnectionProvider {
29+
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
2430
private val connections = mutableSetOf<CoderConnectionMetadata>()
31+
private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm")
32+
2533
override suspend fun connect(parameters: Map<String, String>, requestor: ConnectionRequestor): GatewayConnectionHandle? {
2634
val coderWorkspaceHostname = parameters["coder_workspace_hostname"]
2735
val projectPath = parameters["project_path"]
2836
val ideProductCode = parameters["ide_product_code"]!!
2937
val ideBuildNumber = parameters["ide_build_number"]!!
30-
val ideDownloadLink = parameters["ide_download_link"]
38+
val ideDownloadLink = parameters["ide_download_link"]!!
3139

32-
if (coderWorkspaceHostname != null) {
40+
if (coderWorkspaceHostname != null && projectPath != null) {
3341
val connection = CoderConnectionMetadata(coderWorkspaceHostname)
3442
if (connection in connections) {
3543
logger.warning("There is already a connection started on ${connection.workspaceHostname}")
3644
return null
3745
}
3846
val clientLifetime = LifetimeDefinition()
47+
3948
val credentials = RemoteCredentialsHolder().apply {
4049
setHost(coderWorkspaceHostname)
4150
userName = "coder"
4251
authType = AuthType.OPEN_SSH
4352
}
4453

54+
val sshConfiguration = SshConfig(true).apply {
55+
setHost(coderWorkspaceHostname)
56+
setUsername("coder")
57+
authType = AuthType.OPEN_SSH
58+
}
59+
60+
val ideConfig = IdeInfo(
61+
product = IntelliJPlatformProduct.fromProductCode(ideProductCode)!!,
62+
buildNumber = ideBuildNumber
63+
)
64+
4565
clientLifetime.launchUnderBackgroundProgress("Coder Gateway Deploy", true, true, null) {
4666
val context = SshMultistagePanelContext().apply {
4767
deploy = true
48-
sshConfig = SshConfig(true).apply {
49-
setHost(coderWorkspaceHostname)
50-
setUsername("coder")
51-
authType = AuthType.OPEN_SSH
52-
}
68+
sshConfig = sshConfiguration
5369
remoteProjectPath = projectPath
5470
remoteCommandsExecutor = SshCommandsExecutor.Companion.create(credentials)
5571
downloadMethod = SshDownloadMethod.CustomizedLink
5672
customDownloadLink = ideDownloadLink
57-
ide = IdeInfo(
58-
product = IntelliJPlatformProduct.fromProductCode(ideProductCode)!!,
59-
buildNumber = ideBuildNumber
60-
)
73+
ide = ideConfig
6174
}
62-
val deployPair = async {
75+
launch {
6376
SshDeployFlowUtil.fullDeployCycle(
6477
clientLifetime,
6578
context,
6679
Duration.ofMinutes(10)
6780
)
68-
}.await()
69-
70-
logger.info(">>>$deployPair")
81+
}
7182
}
7283

84+
recentConnectionsService.addRecentConnection(
85+
RecentWorkspaceConnection(
86+
coderWorkspaceHostname,
87+
projectPath,
88+
localTimeFormatter.format(LocalDateTime.now()),
89+
ideProductCode,
90+
ideBuildNumber,
91+
ideDownloadLink,
92+
)
93+
)
94+
7395
return object : GatewayConnectionHandle(clientLifetime) {
7496
override fun createComponent(): JComponent {
7597
return CoderGatewayConnectionComponent(clientLifetime, coderWorkspaceHostname)

src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt

+7
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package com.coder.gateway
22

33
import com.coder.gateway.icons.CoderIcons
44
import com.coder.gateway.views.CoderGatewayConnectorWizardWrapperView
5+
import com.coder.gateway.views.CoderGatewayRecentWorkspaceConnectionsView
56
import com.intellij.ui.components.ActionLink
67
import com.intellij.ui.components.BrowserLink
78
import com.jetbrains.gateway.api.GatewayConnector
89
import com.jetbrains.gateway.api.GatewayConnectorView
10+
import com.jetbrains.gateway.api.GatewayRecentConnections
911
import com.jetbrains.rd.util.lifetime.Lifetime
12+
import java.awt.Component
1013
import javax.swing.Icon
1114
import javax.swing.JComponent
1215

@@ -30,6 +33,10 @@ class CoderGatewayMainView : GatewayConnector {
3033
return BrowserLink(null, "Learn more about Coder Workspaces", null, "https://coder.com/docs/coder/latest/workspaces")
3134
}
3235

36+
override fun getRecentConnections(setContentCallback: (Component) -> Unit): GatewayRecentConnections? {
37+
return CoderGatewayRecentWorkspaceConnectionsView()
38+
}
39+
3340
override fun getTitle(): String {
3441
return CoderGatewayBundle.message("gateway.connector.title")
3542
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.coder.gateway.models
2+
3+
import com.intellij.openapi.components.BaseState
4+
import com.intellij.util.xmlb.annotations.Attribute
5+
6+
class RecentWorkspaceConnection() : BaseState() {
7+
constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String) : this() {
8+
coderWorkspaceHostname = hostname
9+
projectPath = prjPath
10+
lastOpened = openedAt
11+
ideProductCode = productCode
12+
ideBuildNumber = buildNumber
13+
downloadSource = source
14+
}
15+
16+
@get:Attribute
17+
var coderWorkspaceHostname by string()
18+
19+
@get:Attribute
20+
var projectPath by string()
21+
22+
@get:Attribute
23+
var lastOpened by string()
24+
25+
@get:Attribute
26+
var ideProductCode by string()
27+
28+
@get:Attribute
29+
var ideBuildNumber by string()
30+
31+
@get:Attribute
32+
var downloadSource by string()
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.coder.gateway.models
2+
3+
import com.intellij.openapi.components.BaseState
4+
import com.intellij.util.xmlb.annotations.XCollection
5+
6+
class RecentWorkspaceConnectionState : BaseState() {
7+
@get:XCollection
8+
var recentConnections by list<RecentWorkspaceConnection>()
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.coder.gateway.services
2+
3+
import com.coder.gateway.models.RecentWorkspaceConnection
4+
import com.coder.gateway.models.RecentWorkspaceConnectionState
5+
import com.intellij.openapi.components.PersistentStateComponent
6+
import com.intellij.openapi.components.RoamingType
7+
import com.intellij.openapi.components.Service
8+
import com.intellij.openapi.components.State
9+
import com.intellij.openapi.components.Storage
10+
import com.intellij.openapi.diagnostic.Logger
11+
12+
13+
@Service(Service.Level.APP)
14+
@State(name = "CoderRecentWorkspaceConnections", storages = [Storage("coder-recent-workspace-connections.xml", roamingType = RoamingType.DISABLED, exportable = true)])
15+
class CoderRecentWorkspaceConnectionsService : PersistentStateComponent<RecentWorkspaceConnectionState> {
16+
var myState = RecentWorkspaceConnectionState()
17+
18+
fun addRecentConnection(connection: RecentWorkspaceConnection) = myState.recentConnections.add(connection)
19+
20+
fun removeConnection(connection: RecentWorkspaceConnection) = myState.recentConnections.remove(connection)
21+
22+
fun getAllRecentConnections() = myState.recentConnections
23+
24+
override fun getState(): RecentWorkspaceConnectionState = myState
25+
26+
override fun loadState(loadedState: RecentWorkspaceConnectionState) {
27+
myState = loadedState
28+
}
29+
30+
override fun noStateLoaded() {
31+
logger.info("No Coder recent connections loaded")
32+
}
33+
34+
companion object {
35+
val logger = Logger.getInstance(CoderRecentWorkspaceConnectionsService::class.java.simpleName)
36+
}
37+
}
38+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.coder.gateway.views
2+
3+
import com.coder.gateway.CoderGatewayBundle
4+
import com.coder.gateway.icons.CoderIcons
5+
import com.coder.gateway.models.RecentWorkspaceConnection
6+
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
7+
import com.intellij.openapi.components.service
8+
import com.intellij.ui.dsl.builder.TopGap
9+
import com.intellij.ui.dsl.builder.panel
10+
import com.intellij.ui.dsl.gridLayout.HorizontalAlign
11+
import com.intellij.ui.dsl.gridLayout.VerticalAlign
12+
import com.intellij.ui.treeStructure.Tree
13+
import com.intellij.ui.treeStructure.treetable.ListTreeTableModel
14+
import com.intellij.ui.treeStructure.treetable.TreeColumnInfo
15+
import com.intellij.util.ui.JBFont
16+
import com.jetbrains.gateway.api.GatewayRecentConnections
17+
import com.jetbrains.rd.util.lifetime.Lifetime
18+
import javax.swing.JComponent
19+
import javax.swing.JTree
20+
import javax.swing.tree.DefaultMutableTreeNode
21+
import javax.swing.tree.TreeNode
22+
import javax.swing.tree.TreePath
23+
24+
class CoderGatewayRecentWorkspaceConnectionsView : GatewayRecentConnections {
25+
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
26+
27+
private var top = DefaultMutableTreeNode()
28+
private var recentConnectionsTeeModel = ListTreeTableModel(top, emptyArray<TreeColumnInfo>())
29+
private val recentConnectionsView = Tree(recentConnectionsTeeModel)
30+
31+
override val id = "CoderGatewayRecentConnections"
32+
33+
override val recentsIcon = CoderIcons.LOGO_16
34+
35+
override fun createRecentsView(lifetime: Lifetime): JComponent {
36+
val view = panel {
37+
indent {
38+
row {
39+
label(CoderGatewayBundle.message("gateway.connector.recentconnections.title")).applyToComponent {
40+
font = JBFont.h3().asBold()
41+
}
42+
}
43+
row {
44+
scrollCell(recentConnectionsView).resizableColumn().horizontalAlign(HorizontalAlign.FILL).verticalAlign(VerticalAlign.FILL)
45+
cell()
46+
}.topGap(TopGap.NONE).resizableRow()
47+
}
48+
}
49+
recentConnectionsView.apply {
50+
isRootVisible = false
51+
showsRootHandles = false
52+
rowHeight = -1
53+
val mouseListeners = mouseListeners
54+
for (mouseListener in mouseListeners) {
55+
removeMouseListener(mouseListener)
56+
}
57+
cellRenderer = CoderRecentWorkspaceConnectionsTreeRenderer()
58+
}
59+
updateTree()
60+
return view
61+
}
62+
63+
override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title")
64+
65+
override fun updateRecentView() {
66+
updateTree()
67+
}
68+
69+
private fun updateTree() {
70+
val groupedConnections = recentConnectionsService.getAllRecentConnections().groupBy { it.coderWorkspaceHostname }
71+
top.removeAllChildren()
72+
groupedConnections.entries.forEach { (hostname, recentConnections) ->
73+
val hostnameTreeNode = HostnameTreeNode(hostname)
74+
recentConnections.forEach { connectionDetails ->
75+
hostnameTreeNode.add(ProjectTreeNode(connectionDetails))
76+
}
77+
top.add(hostnameTreeNode)
78+
}
79+
80+
expandAll(recentConnectionsView, TreePath(top))
81+
}
82+
83+
private fun expandAll(tree: JTree, parent: TreePath) {
84+
val node: TreeNode = parent.lastPathComponent as TreeNode
85+
if (node.childCount >= 0) {
86+
val e = node.children()
87+
while (e.hasMoreElements()) {
88+
val n: TreeNode = e.nextElement() as TreeNode
89+
val path: TreePath = parent.pathByAddingChild(n)
90+
expandAll(tree, path)
91+
}
92+
}
93+
tree.expandPath(parent)
94+
}
95+
}
96+
97+
98+
class HostnameTreeNode(val hostname: String?) : DefaultMutableTreeNode()
99+
100+
class ProjectTreeNode(val connectionDetails: RecentWorkspaceConnection) : DefaultMutableTreeNode()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.coder.gateway.views
2+
3+
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
4+
import com.intellij.ui.components.ActionLink
5+
import com.intellij.ui.dsl.builder.BottomGap
6+
import com.intellij.ui.dsl.builder.RowLayout
7+
import com.intellij.ui.dsl.builder.panel
8+
import com.intellij.ui.dsl.gridLayout.HorizontalAlign
9+
import com.intellij.util.ui.JBFont
10+
import com.intellij.util.ui.JBUI
11+
import com.jetbrains.gateway.api.GatewayUI
12+
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
13+
import kotlinx.coroutines.CoroutineScope
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.launch
16+
import java.awt.Component
17+
import javax.swing.JTree
18+
import javax.swing.tree.DefaultMutableTreeNode
19+
import javax.swing.tree.TreeCellRenderer
20+
21+
class CoderRecentWorkspaceConnectionsTreeRenderer : TreeCellRenderer {
22+
private val cs = CoroutineScope(Dispatchers.Main)
23+
24+
override fun getTreeCellRendererComponent(tree: JTree?, value: Any?, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean): Component {
25+
if (value == null || value !is DefaultMutableTreeNode) {
26+
return panel { }
27+
}
28+
29+
when {
30+
value is HostnameTreeNode -> {
31+
return panel {
32+
indent {
33+
row {
34+
if (value.hostname != null) {
35+
label(value.hostname).applyToComponent {
36+
font = JBFont.h3().asBold()
37+
}.horizontalAlign(HorizontalAlign.LEFT)
38+
cell()
39+
}
40+
}
41+
}
42+
}
43+
}
44+
value is ProjectTreeNode -> {
45+
val product = IntelliJPlatformProduct.fromProductCode(value.connectionDetails.ideProductCode!!)!!
46+
return panel {
47+
indent {
48+
row {
49+
icon(product.icon)
50+
cell(ActionLink(value.connectionDetails.projectPath!!) {
51+
cs.launch {
52+
GatewayUI.getInstance().connect(
53+
mapOf(
54+
"type" to "coder",
55+
"coder_workspace_hostname" to "coder.${value.connectionDetails.coderWorkspaceHostname}",
56+
"project_path" to value.connectionDetails.projectPath!!,
57+
"ide_product_code" to "${product.productCode}",
58+
"ide_build_number" to "${value.connectionDetails.ideBuildNumber}",
59+
"ide_download_link" to "${value.connectionDetails.downloadSource}"
60+
)
61+
)
62+
}
63+
})
64+
cell()
65+
label("Last opened: ${value.connectionDetails.lastOpened}").horizontalAlign(HorizontalAlign.RIGHT).applyToComponent {
66+
foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
67+
font = ComponentPanelBuilder.getCommentFont(font)
68+
}
69+
}.layout(RowLayout.PARENT_GRID).bottomGap(BottomGap.MEDIUM)
70+
}
71+
}
72+
}
73+
}
74+
return panel { }
75+
}
76+
}

0 commit comments

Comments
 (0)