@@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException
1010import com.coder.toolbox.sdk.v2.models.NetworkMetrics
1111import com.coder.toolbox.sdk.v2.models.Workspace
1212import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
13+ import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
1314import com.coder.toolbox.session.SessionId
1415import com.coder.toolbox.session.SessionIdRegistry
1516import com.coder.toolbox.util.OS
@@ -40,13 +41,23 @@ import kotlinx.coroutines.flow.StateFlow
4041import kotlinx.coroutines.flow.update
4142import kotlinx.coroutines.isActive
4243import kotlinx.coroutines.launch
44+ import kotlinx.coroutines.withContext
4345import kotlinx.coroutines.withTimeout
4446import java.io.File
4547import java.nio.file.Path
48+ import kotlin.coroutines.cancellation.CancellationException
4649import kotlin.time.Duration.Companion.minutes
4750import kotlin.time.Duration.Companion.seconds
4851
4952private val POLL_INTERVAL = 5 .seconds
53+ private val ANSI_ESCAPE_SEQUENCE = Regex (" \u001B \\ [[0-?]*[ -/]*[@-~]" )
54+ private val IN_PROGRESS_BUILD_STATUSES = setOf (
55+ WorkspaceStatus .PENDING ,
56+ WorkspaceStatus .STARTING ,
57+ WorkspaceStatus .STOPPING ,
58+ )
59+
60+ private fun String.stripAnsiEscapeSequences (): String = replace(ANSI_ESCAPE_SEQUENCE , " " )
5061
5162private fun environmentId (workspace : Workspace , agent : WorkspaceAgent ? ): String =
5263 agent?.let { " ${workspace.name} .${it.name} " } ? : workspace.name
@@ -58,6 +69,20 @@ private fun OS?.displayName(): String = when (this) {
5869 null -> " unknown-OS"
5970}
6071
72+ private fun WorkspaceAndAgentStatus.toEnvironmentDescription (
73+ context : CoderToolboxContext ,
74+ ): EnvironmentDescription {
75+ val buildStatus = workspace.latestBuild.status
76+ return if (buildStatus in IN_PROGRESS_BUILD_STATUSES ) {
77+ EnvironmentDescription .Progress (
78+ context.i18n.pnotr(" Building ${workspace.name} (${buildStatus.name.lowercase()} )…" ),
79+ indeterminate = true ,
80+ )
81+ } else {
82+ EnvironmentDescription .General (context.i18n.pnotr(workspace.templateDisplayName))
83+ }
84+ }
85+
6186/* *
6287 * Represents a workspace, or a workspace and agent combination when an agent is available.
6388 *
@@ -80,7 +105,7 @@ class CoderRemoteEnvironment(
80105 override val state: MutableStateFlow <RemoteEnvironmentState > =
81106 MutableStateFlow (environmentStatus.toRemoteEnvironmentState(context))
82107 override val description: MutableStateFlow <EnvironmentDescription > =
83- MutableStateFlow (EnvironmentDescription . General (context.i18n.pnotr(workspace.templateDisplayName) ))
108+ MutableStateFlow (environmentStatus.toEnvironmentDescription (context))
84109 override val additionalEnvironmentInformation: MutableMap <LocalizableString , String > = mutableMapOf ()
85110 override val actionsList: MutableStateFlow <List <ActionDescription >> = MutableStateFlow (emptyList())
86111
@@ -101,6 +126,24 @@ class CoderRemoteEnvironment(
101126 internal fun currentSessionId (): SessionId ? =
102127 agent?.let { SessionIdRegistry .findSession(workspace.name, it.name) }
103128
129+ private suspend fun <T > withProgress (message : String , action : suspend () -> T ): T {
130+ description.value = EnvironmentDescription .Progress (context.i18n.ptrl(message), indeterminate = true )
131+ return try {
132+ action()
133+ } finally {
134+ description.value = environmentStatus.toEnvironmentDescription(context)
135+ }
136+ }
137+
138+ private fun showProgress (output : String ) {
139+ val progressMessage = output.stripAnsiEscapeSequences().trim()
140+ if (progressMessage.isEmpty()) return
141+ description.value = EnvironmentDescription .Progress (
142+ context.i18n.pnotr(progressMessage),
143+ indeterminate = true ,
144+ )
145+ }
146+
104147 private fun refreshAvailableActions () {
105148 val actions = mutableListOf<ActionDescription >()
106149 context.logger.debug(" Refreshing available actions for workspace $id with status: $environmentStatus " )
@@ -139,45 +182,64 @@ class CoderRemoteEnvironment(
139182 if (environmentStatus.canStart()) {
140183 if (workspace.outdated) {
141184 actions.add(Action (context, " Update and start" ) {
142- context.logger.debug(" Updating and starting $id ..." )
143- val build = client.updateWorkspace(workspace)
144- update(workspace.copy(latestBuild = build), agent)
145- workspaceRefreshTrigger.trySend(true )
185+ withProgress(" Updating and starting workspace…" ) {
186+ context.logger.debug(" Updating and starting $id ..." )
187+ val build = client.updateWorkspace(workspace)
188+ applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
189+ workspaceRefreshTrigger.trySend(true )
190+ }
146191 })
147192 } else {
148193 actions.add(Action (context, " Start" ) {
149- context.logger.debug(" Starting $id ... " )
150- context.cs
151- .launch(CoroutineName (" Start Workspace Action CLI Runner" ) + Dispatchers .IO ) {
152- cli.startWorkspace(WorkspaceAddress .from(workspace))
194+ withProgress(" Starting workspace…" ) {
195+ context.logger.debug(" Starting $id ... " )
196+ val previousStatus = environmentStatus
197+ // The CLI can take a while before Coder reports a new workspace state. Show
198+ // the pending state immediately and remove the Start action in the meantime.
199+ updateStatus(WorkspaceAndAgentStatus .Queued (workspace))
200+ refreshAvailableActions()
201+ var commandSucceeded = false
202+ try {
203+ withContext(Dispatchers .IO ) {
204+ cli.startWorkspace(
205+ WorkspaceAddress .from(workspace),
206+ showTextProgress = ::showProgress,
207+ )
208+ }
209+ commandSucceeded = true
153210 workspaceRefreshTrigger.trySend(true )
211+ } finally {
212+ if (! commandSucceeded) {
213+ updateStatus(previousStatus)
214+ refreshAvailableActions()
215+ }
154216 }
155- // cli takes 15 seconds to move the workspace in queueing/starting state
156- // while the user won't see anything happening in TBX after start is clicked
157- // During those 15 seconds we work around by forcing a `Queuing` state
158- updateStatus(WorkspaceAndAgentStatus .Queued (workspace))
159- // force refresh of the actions list (Start should no longer be available)
160- refreshAvailableActions()
217+ }
161218 })
162219 }
163220 }
164221 if (environmentStatus.canStop()) {
165222 if (workspace.outdated) {
166223 actions.add(
167224 Action (context, " Update and restart" ) {
168- context.logger.debug(currentSessionId(), " Updating and re-starting $id ..." )
169- val build = client.updateWorkspace(workspace)
170- update(workspace.copy(latestBuild = build), agent)
171- workspaceRefreshTrigger.trySend(true )
225+ withProgress(" Updating and restarting workspace…" ) {
226+ context.logger.debug(currentSessionId(), " Updating and re-starting $id ..." )
227+ val build = client.updateWorkspace(workspace)
228+ applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
229+ workspaceRefreshTrigger.trySend(true )
230+ }
172231 }.withCurrentSessionId(::currentSessionId)
173232 )
174233 }
175234 actions.add(
176235 Action (context, " Stop" ) {
177- tryStopSshConnection()
178- context.logger.debug(currentSessionId(), " Stopping $id ..." )
179- val build = client.stopWorkspace(workspace)
180- update(workspace.copy(latestBuild = build), agent)
236+ withProgress(" Stopping workspace…" ) {
237+ tryStopSshConnection()
238+ context.logger.debug(currentSessionId(), " Stopping $id ..." )
239+ val build = client.stopWorkspace(workspace)
240+ applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
241+ workspaceRefreshTrigger.trySend(true )
242+ }
181243 }.withCurrentSessionId(::currentSessionId)
182244 )
183245 }
@@ -199,8 +261,10 @@ class CoderRemoteEnvironment(
199261 context.i18n.ptrl(" Cancel" )
200262 )
201263 if (confirmation == workspace.name) {
202- context.logger.debug(currentSessionId(), " Deleting $id ..." )
203- deleteWorkspace()
264+ withProgress(" Deleting workspace…" ) {
265+ context.logger.debug(currentSessionId(), " Deleting $id ..." )
266+ deleteWorkspace()
267+ }
204268 }
205269 }.withCurrentSessionId(::currentSessionId)
206270 )
@@ -346,7 +410,12 @@ class CoderRemoteEnvironment(
346410 /* *
347411 * Update the workspace/agent status to the listeners, if it has changed.
348412 */
349- fun update (newWorkspace : Workspace , newAgent : WorkspaceAgent ? ) {
413+ suspend fun update (newWorkspace : Workspace , newAgent : WorkspaceAgent ? ) {
414+ applyWorkspaceSnapshot(newWorkspace, newAgent)
415+ updateBuildProgress(newWorkspace)
416+ }
417+
418+ private fun applyWorkspaceSnapshot (newWorkspace : Workspace , newAgent : WorkspaceAgent ? ) {
350419 if (workspace.latestBuild == newWorkspace.latestBuild) {
351420 return
352421 }
@@ -363,6 +432,23 @@ class CoderRemoteEnvironment(
363432 refreshAvailableActions()
364433 }
365434
435+ private suspend fun updateBuildProgress (newWorkspace : Workspace ) {
436+ val build = newWorkspace.latestBuild
437+ if (build.status !in IN_PROGRESS_BUILD_STATUSES ) return
438+ val latestLog = try {
439+ client.workspaceBuildLogs(build.id).lastOrNull()
440+ } catch (ex: CancellationException ) {
441+ throw ex
442+ } catch (ex: Exception ) {
443+ context.logger.warn(
444+ currentSessionId(),
445+ ex,
446+ " Failed to retrieve progress for workspace build ${build.id} " ,
447+ )
448+ return
449+ }
450+ latestLog?.output?.let (::showProgress)
451+ }
366452
367453 private fun updateStatus (
368454 newState : WorkspaceAndAgentStatus ,
@@ -380,6 +466,9 @@ class CoderRemoteEnvironment(
380466 state.update {
381467 environmentStatus.toRemoteEnvironmentState(context)
382468 }
469+ description.update {
470+ environmentStatus.toEnvironmentDescription(context)
471+ }
383472 val message =
384473 " Overall status for workspace $id changed from ${previousEnvironmentStatus.label} " +
385474 " to ${environmentStatus.label} . " +
0 commit comments