Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

revise recent projects flow to be less confusing #464

Merged
merged 11 commits into from
Aug 19, 2024
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## Unreleased

- The "Recents" view has been updated to have a new flow.
Before, there were separate controls for managing the workspace and then you
could click a link to launch a project (clicking a link would also start a stopped workspace automatically).
Now, there are no workspace controls, just links which start the workspace automatically when needed.
The links are enabled when the workspace is STOPPED, CANCELED, FAILED, STARTING, RUNNING. These states represent
valid times to start a workspace and connect, or to simply connect to a running one or one that's already starting.
We also use a spinner icon when workspaces are in a transition state (STARTING, CANCELING, DELETING, STOPPING)
to give context for why a link might be disabled or a connection might take longer than usual to establish.

## 2.13.1 - 2024-07-19

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ import com.coder.gateway.services.CoderRestClientService
import com.coder.gateway.services.CoderSettingsService
import com.coder.gateway.util.humanizeConnectionError
import com.coder.gateway.util.toURL
import com.coder.gateway.util.withPath
import com.coder.gateway.util.withoutNull
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ModalityState
Expand Down Expand Up @@ -56,6 +54,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import java.util.Locale
Expand Down Expand Up @@ -175,15 +174,15 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
val workspaceWithAgent = deployment?.items?.firstOrNull { it.workspace.name == workspaceName }
val status =
if (deploymentError != null) {
Triple(UIUtil.getBalloonErrorIcon(), UIUtil.getErrorForeground(), deploymentError)
Triple(UIUtil.getErrorForeground(), deploymentError, UIUtil.getBalloonErrorIcon())
} else if (workspaceWithAgent != null) {
Triple(
workspaceWithAgent.status.icon,
workspaceWithAgent.status.statusColor(),
workspaceWithAgent.status.description,
null,
)
} else {
Triple(AnimatedIcon.Default.INSTANCE, UIUtil.getContextHelpForeground(), "Querying workspace status...")
Triple(UIUtil.getContextHelpForeground(), "Querying workspace status...", AnimatedIcon.Default())
}
val gap =
if (top) {
Expand All @@ -193,11 +192,6 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
TopGap.MEDIUM
}
row {
icon(status.first).applyToComponent {
foreground = status.second
}.align(AlignX.LEFT).gap(RightGap.SMALL).applyToComponent {
size = Dimension(JBUI.scale(16), JBUI.scale(16))
}
label(workspaceName).applyToComponent {
font = JBFont.h3().asBold()
}.align(AlignX.LEFT).gap(RightGap.SMALL)
Expand All @@ -206,94 +200,48 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
font = ComponentPanelBuilder.getCommentFont(font)
}
label("").resizableColumn().align(AlignX.FILL)
actionButton(
object : DumbAwareAction(
CoderGatewayBundle.message("gateway.connector.recent-connections.start.button.tooltip"),
"",
CoderIcons.RUN,
) {
override fun actionPerformed(e: AnActionEvent) {
withoutNull(workspaceWithAgent?.workspace, deployment?.client) { workspace, client ->
jobs[workspace.id]?.cancel()
jobs[workspace.id] =
cs.launch(ModalityState.current().asContextElement()) {
withContext(Dispatchers.IO) {
try {
client.startWorkspace(workspace)
fetchWorkspaces()
} catch (e: Exception) {
logger.error("Could not start workspace ${workspace.name}", e)
}
}
}
}
}
},
).applyToComponent {
isEnabled =
listOf(
WorkspaceStatus.STOPPED,
WorkspaceStatus.FAILED,
).contains(workspaceWithAgent?.workspace?.latestBuild?.status)
}
.gap(RightGap.SMALL)
actionButton(
object : DumbAwareAction(
CoderGatewayBundle.message("gateway.connector.recent-connections.stop.button.tooltip"),
"",
CoderIcons.STOP,
) {
override fun actionPerformed(e: AnActionEvent) {
withoutNull(workspaceWithAgent?.workspace, deployment?.client) { workspace, client ->
jobs[workspace.id]?.cancel()
jobs[workspace.id] =
cs.launch(ModalityState.current().asContextElement()) {
withContext(Dispatchers.IO) {
try {
client.stopWorkspace(workspace)
fetchWorkspaces()
} catch (e: Exception) {
logger.error("Could not stop workspace ${workspace.name}", e)
}
}
}
}
}
},
).applyToComponent { isEnabled = workspaceWithAgent?.workspace?.latestBuild?.status == WorkspaceStatus.RUNNING }
.gap(RightGap.SMALL)
actionButton(
object : DumbAwareAction(
CoderGatewayBundle.message("gateway.connector.recent-connections.terminal.button.tooltip"),
"",
CoderIcons.OPEN_TERMINAL,
) {
override fun actionPerformed(e: AnActionEvent) {
withoutNull(workspaceWithAgent, deployment?.client) { ws, client ->
val link = client.url.withPath("/me/${ws.name}/terminal")
BrowserUtil.browse(link.toString())
}
}
},
)
}.topGap(gap)

val enableLinks = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED, WorkspaceStatus.STARTING, WorkspaceStatus.RUNNING).contains(workspaceWithAgent?.workspace?.latestBuild?.status)
val inLoadingState = listOf(WorkspaceStatus.STARTING, WorkspaceStatus.CANCELING, WorkspaceStatus.DELETING, WorkspaceStatus.STOPPING).contains(workspaceWithAgent?.workspace?.latestBuild?.status)

// We only display an API error on the first workspace rather than duplicating it on each workspace.
if (deploymentError == null || showError) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to add this check back or something similar to it. It is poorly named but basically it was making sure we only display an API error on the first workspace rather than duplicating it on each workspace (it was pretty noisy, especially since the API errors can get long).

For example, say you have 5 workspaces and your token expires, you see a message about being unauthorized and to check your token 5 times on the page.

Or we could group workspaces under a deployment heading and show the API error under the deployment heading, but that might be out of scope for this PR.

Copy link
Collaborator Author

@bcpeinhardt bcpeinhardt Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like atm the moment I actually have this inside the connections.foEach loop (which makes me think with multiple WorkpaceProjectIDE combos it would render multiple times). I didn't notice because I was visually testing with one recent connection.
I'll pull it out of the loop and add the check.

^^^ My adhd is going to quickly become a driving force for building out E2E suites.

row {
// There must be a way to make this properly wrap?
label("<html><body style='width:350px;'>" + status.third + "</html>").applyToComponent {
foreground = status.second
if (inLoadingState) {
icon(AnimatedIcon.Default())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at all a blocker and completely optional, more just idly musing, but I wonder if we want to turn the pair back into a triple and put a nullable icon in there, which would let us also show a spinner while the query is running as well (like it used to) and bring back the error icon for API errors (if we want, honestly the red color is probably sufficient).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding the error icon back is a good idea and fits in this PR :)

if (status.third != null) {
icon(status.third!!)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if this is more Kotlin-y but I think you can also do something like this over the assertion:

status.third?.let { icon (it) }

Admittedly untested though

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I low key hate that they named a function let 💀 TBH I think the assertion here is clearer, but if that's the kotlin way then I'm game haha. I could also do a withoutNull I suppose.

Copy link
Member

@code-asher code-asher Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withoutNull throws if null but here we are OK with the status potentially being null, so I think it would not quite fit here.

I think the main issue with assertions is that they bypass the safety provided to you by the language, which feels dangerous for an unclear benefit.

If you see a !! you need to reason about the entire code and determine that status.third is not going to be reassigned (and you need to do this every time related code is refactored), but we should let the machine do it for us. 😆 Admittedly it is pretty trivial in this case, but still!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I meant use withoutNull inside the if check, but you're totally right the let method is the best option here, I just wish they called it somewhere else lol.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahaha yeah and I swear I have to look it up every time because I always forget it is called let

label("<html><body style='width:350px;'>" + status.second + "</html>").applyToComponent {
foreground = status.first
}
}
}

connections.forEach { workspaceProjectIDE ->
row {
icon(workspaceProjectIDE.ideProduct.icon)
cell(
ActionLink(workspaceProjectIDE.projectPathDisplay) {
CoderRemoteConnectionHandle().connect { workspaceProjectIDE }
GatewayUI.getInstance().reset()
},
)
if (enableLinks) {
cell(
ActionLink(workspaceProjectIDE.projectPathDisplay) {
withoutNull(deployment?.client, workspaceWithAgent?.workspace) { client, workspace ->
CoderRemoteConnectionHandle().connect {
if (listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED).contains(workspace.latestBuild.status)) {
client.startWorkspace(workspace)
Comment on lines +233 to +234
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #465 for future improvement

}
workspaceProjectIDE
}
GatewayUI.getInstance().reset()
}
},
)
} else {
label(workspaceProjectIDE.projectPathDisplay).applyToComponent {
foreground = Color.GRAY
}
}
label("").resizableColumn().align(AlignX.FILL)
label(workspaceProjectIDE.ideName).applyToComponent {
foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
Expand Down
Loading