@@ -6,16 +6,23 @@ import com.coder.gateway.CoderGatewayBundle
66import com.coder.gateway.CoderGatewayConstants
77import com.coder.gateway.icons.CoderIcons
88import 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
914import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
1015import com.coder.gateway.toWorkspaceParams
1116import com.intellij.icons.AllIcons
1217import com.intellij.ide.BrowserUtil
1318import com.intellij.openapi.Disposable
1419import com.intellij.openapi.actionSystem.AnActionEvent
1520import com.intellij.openapi.components.service
21+ import com.intellij.openapi.diagnostic.Logger
1622import com.intellij.openapi.project.DumbAwareAction
1723import com.intellij.openapi.ui.panel.ComponentPanelBuilder
1824import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
25+ import com.intellij.ui.AnimatedIcon
1926import com.intellij.ui.DocumentAdapter
2027import com.intellij.ui.SearchTextField
2128import com.intellij.ui.components.ActionLink
@@ -26,40 +33,70 @@ import com.intellij.ui.dsl.builder.BottomGap
2633import com.intellij.ui.dsl.builder.RightGap
2734import com.intellij.ui.dsl.builder.TopGap
2835import 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
2939import com.intellij.util.ui.JBFont
3040import com.intellij.util.ui.JBUI
41+ import com.intellij.util.ui.UIUtil
3142import com.jetbrains.gateway.api.GatewayRecentConnections
3243import com.jetbrains.gateway.api.GatewayUI
3344import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
3445import com.jetbrains.rd.util.lifetime.Lifetime
3546import kotlinx.coroutines.CoroutineScope
3647import kotlinx.coroutines.Dispatchers
48+ import kotlinx.coroutines.Job
3749import kotlinx.coroutines.cancel
50+ import kotlinx.coroutines.delay
51+ import kotlinx.coroutines.isActive
3852import kotlinx.coroutines.launch
53+ import kotlinx.coroutines.withContext
3954import java.awt.Component
4055import java.awt.Dimension
41- import java.util.*
56+ import java.nio.file.Path
57+ import java.util.Locale
4258import javax.swing.JComponent
4359import javax.swing.JLabel
4460import javax.swing.event.DocumentEvent
4561
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+
4675class CoderGatewayRecentWorkspaceConnectionsView (private val setContentCallback : (Component ) -> Unit ) : GatewayRecentConnections, Disposable {
4776 private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService >()
4877 private val cs = CoroutineScope (Dispatchers .Main )
4978
5079 private val recentWorkspacesContentPanel = JBScrollPane ()
5180
5281 private lateinit var searchBar: SearchTextField
82+ private var filterString: String? = null
5383
5484 override val id = CoderGatewayConstants .GATEWAY_RECENT_CONNECTIONS_ID
5585
5686 override val recentsIcon = CoderIcons .LOGO_16
5787
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+
5895 override fun createRecentsView (lifetime : Lifetime ): JComponent {
5996 return panel {
6097 indent {
6198 row {
62- label(CoderGatewayBundle .message(" gateway.connector.recentconnections .title" )).applyToComponent {
99+ label(CoderGatewayBundle .message(" gateway.connector.recent-connections .title" )).applyToComponent {
63100 font = JBFont .h3().asBold()
64101 }
65102 panel {
@@ -71,17 +108,14 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
71108 textEditor.border = JBUI .Borders .empty(2 , 5 , 2 , 0 )
72109 addDocumentListener(object : DocumentAdapter () {
73110 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()
79113 }
80114 })
81115 }.component
82116
83117 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 ) {
85119 override fun actionPerformed (e : AnActionEvent ) {
86120 setContentCallback(CoderGatewayConnectorWizardWrapperView ().component)
87121 }
@@ -106,27 +140,79 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
106140 override fun getRecentsTitle () = CoderGatewayBundle .message(" gateway.connector.title" )
107141
108142 override fun updateRecentView () {
109- val groupedConnections = recentConnectionsService.getAllRecentConnections()
110- .filter { it.coderWorkspaceHostname != null }
111- .groupBy { it.coderWorkspaceHostname!! }
112- updateContentView(groupedConnections)
143+ triggerWorkspacePolling()
144+ updateContentView()
113145 }
114146
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!! }
116152 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 }
118162 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 {
120189 font = JBFont .h3().asBold()
121190 }.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 ) {
193+ override fun actionPerformed (e : AnActionEvent ) {
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 ) {
123201 override fun actionPerformed (e : AnActionEvent ) {
124- BrowserUtil .browse(recentConnections[0 ].webTerminalLink ? : " " )
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 ? : " " )
125211 }
126212 })
127213 }.topGap(TopGap .MEDIUM )
128214
129- recentConnections .forEach { connectionDetails ->
215+ connections .forEach { connectionDetails ->
130216 val product = IntelliJPlatformProduct .fromProductCode(connectionDetails.ideProductCode!! )!!
131217 row {
132218 icon(product.icon)
@@ -140,7 +226,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
140226 foreground = JBUI .CurrentTheme .ContextHelp .FOREGROUND
141227 font = ComponentPanelBuilder .getCommentFont(font)
142228 }
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 ) {
144230 override fun actionPerformed (e : AnActionEvent ) {
145231 recentConnectionsService.removeConnection(connectionDetails)
146232 updateRecentView()
@@ -151,11 +237,88 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
151237 }
152238 }.apply {
153239 background = WelcomeScreenUIManager .getMainAssociatedComponentBackground()
154- border = JBUI .Borders .empty(12 , 0 , 0 , 12 )
240+ border = JBUI .Borders .empty(12 , 0 , 12 , 12 )
155241 }
156242 }
157243
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+ if (recentWorkspacesContentPanel.isShowing) {
279+ fetchWorkspaces()
280+ } else {
281+ logger.info(" View not visible; aborting poll" )
282+ poller?.cancel()
283+ }
284+ delay(5000 )
285+ }
286+ }
287+ }
288+
289+ /* *
290+ * Update each deployment with their latest workspaces.
291+ */
292+ private suspend fun fetchWorkspaces () {
293+ withContext(Dispatchers .IO ) {
294+ deployments.values
295+ .filter { it.error == null && it.client != null }
296+ .forEach { deployment ->
297+ val url = deployment.client!! .url
298+ try {
299+ deployment.workspaces = deployment.client!!
300+ .workspaces().flatMap { it.toAgentModels() }
301+ } catch (e: Exception ) {
302+ logger.error(" Failed to fetch workspaces from $url " , e)
303+ deployment.error = e.message ? : " Request failed without further details"
304+ }
305+ }
306+ }
307+ withContext(Dispatchers .Main ) {
308+ updateContentView()
309+ }
310+ }
311+
312+ // Note that this is *not* called when you navigate away from the page so
313+ // check for visibility if you want to avoid work while the panel is not
314+ // displaying.
158315 override fun dispose () {
316+ logger.info(" Disposing recent view" )
159317 cs.cancel()
318+ poller?.cancel()
319+ }
320+
321+ companion object {
322+ val logger = Logger .getInstance(CoderGatewayRecentWorkspaceConnectionsView ::class .java.simpleName)
160323 }
161- }
324+ }
0 commit comments