@@ -6,16 +6,23 @@ import com.coder.gateway.CoderGatewayBundle
6
6
import com.coder.gateway.CoderGatewayConstants
7
7
import com.coder.gateway.icons.CoderIcons
8
8
import com.coder.gateway.models.RecentWorkspaceConnection
9
+ import com.coder.gateway.models.WorkspaceAgentModel
10
+ import com.coder.gateway.sdk.CoderRestClient
11
+ import com.coder.gateway.sdk.toURL
12
+ import com.coder.gateway.sdk.v2.models.WorkspaceStatus
13
+ import com.coder.gateway.sdk.v2.models.toAgentModels
9
14
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
10
15
import com.coder.gateway.toWorkspaceParams
11
16
import com.intellij.icons.AllIcons
12
17
import com.intellij.ide.BrowserUtil
13
18
import com.intellij.openapi.Disposable
14
19
import com.intellij.openapi.actionSystem.AnActionEvent
15
20
import com.intellij.openapi.components.service
21
+ import com.intellij.openapi.diagnostic.Logger
16
22
import com.intellij.openapi.project.DumbAwareAction
17
23
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
18
24
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
25
+ import com.intellij.ui.AnimatedIcon
19
26
import com.intellij.ui.DocumentAdapter
20
27
import com.intellij.ui.SearchTextField
21
28
import com.intellij.ui.components.ActionLink
@@ -26,40 +33,70 @@ import com.intellij.ui.dsl.builder.BottomGap
26
33
import com.intellij.ui.dsl.builder.RightGap
27
34
import com.intellij.ui.dsl.builder.TopGap
28
35
import com.intellij.ui.dsl.builder.panel
36
+ import com.intellij.ui.util.maximumWidth
37
+ import com.intellij.ui.util.minimumWidth
38
+ import com.intellij.util.io.readText
29
39
import com.intellij.util.ui.JBFont
30
40
import com.intellij.util.ui.JBUI
41
+ import com.intellij.util.ui.UIUtil
31
42
import com.jetbrains.gateway.api.GatewayRecentConnections
32
43
import com.jetbrains.gateway.api.GatewayUI
33
44
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
34
45
import com.jetbrains.rd.util.lifetime.Lifetime
35
46
import kotlinx.coroutines.CoroutineScope
36
47
import kotlinx.coroutines.Dispatchers
48
+ import kotlinx.coroutines.Job
37
49
import kotlinx.coroutines.cancel
50
+ import kotlinx.coroutines.delay
51
+ import kotlinx.coroutines.isActive
38
52
import kotlinx.coroutines.launch
53
+ import kotlinx.coroutines.withContext
39
54
import java.awt.Component
40
55
import java.awt.Dimension
41
- import java.util.*
56
+ import java.nio.file.Path
57
+ import java.util.Locale
42
58
import javax.swing.JComponent
43
59
import javax.swing.JLabel
44
60
import javax.swing.event.DocumentEvent
45
61
62
+ /* *
63
+ * DeploymentInfo contains everything needed to query the API for a deployment
64
+ * along with the latest workspace responses.
65
+ */
66
+ data class DeploymentInfo (
67
+ // Null if unable to create the client (config directory did not exist).
68
+ var client : CoderRestClient ? = null ,
69
+ // Null if we have not fetched workspaces yet.
70
+ var workspaces : List <WorkspaceAgentModel >? = null ,
71
+ // Null if there have not been any errors yet.
72
+ var error : String? = null ,
73
+ )
74
+
46
75
class CoderGatewayRecentWorkspaceConnectionsView (private val setContentCallback : (Component ) -> Unit ) : GatewayRecentConnections, Disposable {
47
76
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService >()
48
77
private val cs = CoroutineScope (Dispatchers .Main )
49
78
50
79
private val recentWorkspacesContentPanel = JBScrollPane ()
51
80
52
81
private lateinit var searchBar: SearchTextField
82
+ private var filterString: String? = null
53
83
54
84
override val id = CoderGatewayConstants .GATEWAY_RECENT_CONNECTIONS_ID
55
85
56
86
override val recentsIcon = CoderIcons .LOGO_16
57
87
88
+ /* *
89
+ * API clients and workspaces grouped by deployment (designated here by
90
+ * their config directory).
91
+ */
92
+ private var deployments: Map <String , DeploymentInfo > = emptyMap()
93
+ private var poller: Job ? = null
94
+
58
95
override fun createRecentsView (lifetime : Lifetime ): JComponent {
59
96
return panel {
60
97
indent {
61
98
row {
62
- label(CoderGatewayBundle .message(" gateway.connector.recentconnections .title" )).applyToComponent {
99
+ label(CoderGatewayBundle .message(" gateway.connector.recent-connections .title" )).applyToComponent {
63
100
font = JBFont .h3().asBold()
64
101
}
65
102
panel {
@@ -71,17 +108,14 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
71
108
textEditor.border = JBUI .Borders .empty(2 , 5 , 2 , 0 )
72
109
addDocumentListener(object : DocumentAdapter () {
73
110
override fun textChanged (e : DocumentEvent ) {
74
- val toSearchFor = this @applyToComponent.text
75
- val filteredConnections = recentConnectionsService.getAllRecentConnections()
76
- .filter { it.coderWorkspaceHostname != null }
77
- .filter { it.coderWorkspaceHostname!! .lowercase(Locale .getDefault()).contains(toSearchFor) || it.projectPath?.lowercase(Locale .getDefault())?.contains(toSearchFor) ? : false }
78
- updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname!! })
111
+ filterString = this @applyToComponent.text.trim()
112
+ updateContentView()
79
113
}
80
114
})
81
115
}.component
82
116
83
117
actionButton(
84
- object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recentconnections .new.wizard.button.tooltip" ), null , AllIcons .General .Add ) {
118
+ object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recent-connections .new.wizard.button.tooltip" ), null , AllIcons .General .Add ) {
85
119
override fun actionPerformed (e : AnActionEvent ) {
86
120
setContentCallback(CoderGatewayConnectorWizardWrapperView ().component)
87
121
}
@@ -106,27 +140,79 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
106
140
override fun getRecentsTitle () = CoderGatewayBundle .message(" gateway.connector.title" )
107
141
108
142
override fun updateRecentView () {
109
- val groupedConnections = recentConnectionsService.getAllRecentConnections()
110
- .filter { it.coderWorkspaceHostname != null }
111
- .groupBy { it.coderWorkspaceHostname!! }
112
- updateContentView(groupedConnections)
143
+ triggerWorkspacePolling()
144
+ updateContentView()
113
145
}
114
146
115
- private fun updateContentView (groupedConnections : Map <String , List <RecentWorkspaceConnection >>) {
147
+ private fun updateContentView () {
148
+ val connections = recentConnectionsService.getAllRecentConnections()
149
+ .filter { it.coderWorkspaceHostname != null }
150
+ .filter { matchesFilter(it) }
151
+ .groupBy { it.coderWorkspaceHostname!! }
116
152
recentWorkspacesContentPanel.viewport.view = panel {
117
- groupedConnections.entries.forEach { (hostname, recentConnections) ->
153
+ connections.forEach { (hostname, connections) ->
154
+ // The config directory and name will not exist on connections
155
+ // made with 2.3.0 and earlier.
156
+ val name = connections.firstNotNullOfOrNull { it.name }
157
+ val workspaceName = name?.split(" ." , limit = 2 )?.first()
158
+ val configDirectory = connections.firstNotNullOfOrNull { it.configDirectory }
159
+ val deployment = deployments[configDirectory]
160
+ val workspace = deployment?.workspaces
161
+ ?.firstOrNull { it.name == name || it.workspaceName == workspaceName }
118
162
row {
119
- label(hostname).applyToComponent {
163
+ (if (workspace != null ) {
164
+ icon(workspace.agentStatus.icon).applyToComponent {
165
+ foreground = workspace.agentStatus.statusColor()
166
+ toolTipText = workspace.agentStatus.description
167
+ }
168
+ } else if (configDirectory == null || workspaceName == null ) {
169
+ icon(CoderIcons .UNKNOWN ).applyToComponent {
170
+ toolTipText = " Unable to determine workspace status because the configuration directory and/or name were not recorded. To fix, add the connection again."
171
+ }
172
+ } else if (deployment?.error != null ) {
173
+ icon(UIUtil .getBalloonErrorIcon()).applyToComponent {
174
+ toolTipText = deployment.error
175
+ }
176
+ } else if (deployment?.workspaces != null ) {
177
+ icon(UIUtil .getBalloonErrorIcon()).applyToComponent {
178
+ toolTipText = " Workspace $workspaceName does not exist"
179
+ }
180
+ } else {
181
+ icon(AnimatedIcon .Default .INSTANCE ).applyToComponent {
182
+ toolTipText = " Querying workspace status..."
183
+ }
184
+ }).align(AlignX .LEFT ).gap(RightGap .SMALL ).applyToComponent {
185
+ maximumWidth = JBUI .scale(16 )
186
+ minimumWidth = JBUI .scale(16 )
187
+ }
188
+ label(hostname.removePrefix(" coder-jetbrains--" )).applyToComponent {
120
189
font = JBFont .h3().asBold()
121
190
}.align(AlignX .LEFT ).gap(RightGap .SMALL )
122
- actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recentconnections.terminal.button.tooltip" ), " " , CoderIcons .OPEN_TERMINAL ) {
191
+ label(" " ).resizableColumn().align(AlignX .FILL )
192
+ actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recent-connections.start.button.tooltip" ), " " , CoderIcons .RUN ) {
123
193
override fun actionPerformed (e : AnActionEvent ) {
124
- BrowserUtil .browse(recentConnections[0 ].webTerminalLink ? : " " )
194
+ if (workspace != null ) {
195
+ deployment.client?.startWorkspace(workspace.workspaceID, workspace.workspaceName)
196
+ cs.launch { fetchWorkspaces() }
197
+ }
198
+ }
199
+ }).applyToComponent { isEnabled = listOf (WorkspaceStatus .STOPPED , WorkspaceStatus .FAILED ).contains(workspace?.workspaceStatus) }.gap(RightGap .SMALL )
200
+ actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recent-connections.stop.button.tooltip" ), " " , CoderIcons .STOP ) {
201
+ override fun actionPerformed (e : AnActionEvent ) {
202
+ if (workspace != null ) {
203
+ deployment.client?.stopWorkspace(workspace.workspaceID, workspace.workspaceName)
204
+ cs.launch { fetchWorkspaces() }
205
+ }
206
+ }
207
+ }).applyToComponent { isEnabled = workspace?.workspaceStatus == WorkspaceStatus .RUNNING }.gap(RightGap .SMALL )
208
+ actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recent-connections.terminal.button.tooltip" ), " " , CoderIcons .OPEN_TERMINAL ) {
209
+ override fun actionPerformed (e : AnActionEvent ) {
210
+ BrowserUtil .browse(connections[0 ].webTerminalLink ? : " " )
125
211
}
126
212
})
127
213
}.topGap(TopGap .MEDIUM )
128
214
129
- recentConnections .forEach { connectionDetails ->
215
+ connections .forEach { connectionDetails ->
130
216
val product = IntelliJPlatformProduct .fromProductCode(connectionDetails.ideProductCode!! )!!
131
217
row {
132
218
icon(product.icon)
@@ -140,7 +226,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
140
226
foreground = JBUI .CurrentTheme .ContextHelp .FOREGROUND
141
227
font = ComponentPanelBuilder .getCommentFont(font)
142
228
}
143
- actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recentconnections .remove.button.tooltip" ), " " , CoderIcons .DELETE ) {
229
+ actionButton(object : DumbAwareAction (CoderGatewayBundle .message(" gateway.connector.recent-connections .remove.button.tooltip" ), " " , CoderIcons .DELETE ) {
144
230
override fun actionPerformed (e : AnActionEvent ) {
145
231
recentConnectionsService.removeConnection(connectionDetails)
146
232
updateRecentView()
@@ -151,11 +237,80 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
151
237
}
152
238
}.apply {
153
239
background = WelcomeScreenUIManager .getMainAssociatedComponentBackground()
154
- border = JBUI .Borders .empty(12 , 0 , 0 , 12 )
240
+ border = JBUI .Borders .empty(12 , 0 , 12 , 12 )
241
+ }
242
+ }
243
+
244
+ /* *
245
+ * Return true if the connection matches the current filter.
246
+ */
247
+ private fun matchesFilter (connection : RecentWorkspaceConnection ): Boolean {
248
+ return filterString.isNullOrBlank()
249
+ || connection.coderWorkspaceHostname?.lowercase(Locale .getDefault())?.contains(filterString!! ) == true
250
+ || connection.projectPath?.lowercase(Locale .getDefault())?.contains(filterString!! ) == true
251
+ }
252
+
253
+ /* *
254
+ * Start polling for workspaces if not already started.
255
+ */
256
+ private fun triggerWorkspacePolling () {
257
+ deployments = recentConnectionsService.getAllRecentConnections()
258
+ .mapNotNull { it.configDirectory }.toSet()
259
+ .associateWith { dir ->
260
+ deployments[dir] ? : try {
261
+ val url = Path .of(dir).resolve(" url" ).readText()
262
+ val token = Path .of(dir).resolve(" session" ).readText()
263
+ DeploymentInfo (CoderRestClient (url.toURL(), token))
264
+ } catch (e: Exception ) {
265
+ logger.error(" Unable to create client from $dir " , e)
266
+ DeploymentInfo (error = " Error trying to read $dir : ${e.message} " )
267
+ }
268
+ }
269
+
270
+ if (poller?.isActive == true ) {
271
+ logger.info(" Refusing to start already-started poller" )
272
+ return
273
+ }
274
+
275
+ logger.info(" Starting poll loop" )
276
+ poller = cs.launch {
277
+ while (isActive) {
278
+ fetchWorkspaces()
279
+ delay(5000 )
280
+ }
281
+ }
282
+ }
283
+
284
+ /* *
285
+ * Update each deployment with their latest workspaces.
286
+ */
287
+ private suspend fun fetchWorkspaces () {
288
+ withContext(Dispatchers .IO ) {
289
+ deployments.values
290
+ .filter { it.error == null && it.client != null }
291
+ .forEach { deployment ->
292
+ val url = deployment.client!! .url
293
+ try {
294
+ deployment.workspaces = deployment.client!!
295
+ .workspaces().flatMap { it.toAgentModels() }
296
+ } catch (e: Exception ) {
297
+ logger.error(" Failed to fetch workspaces from $url " , e)
298
+ deployment.error = e.message ? : " Request failed without further details"
299
+ }
300
+ }
301
+ }
302
+ withContext(Dispatchers .Main ) {
303
+ updateContentView()
155
304
}
156
305
}
157
306
158
307
override fun dispose () {
308
+ logger.info(" Disposing recent view" )
159
309
cs.cancel()
310
+ poller?.cancel()
311
+ }
312
+
313
+ companion object {
314
+ val logger = Logger .getInstance(CoderGatewayRecentWorkspaceConnectionsView ::class .java.simpleName)
160
315
}
161
- }
316
+ }
0 commit comments