| 1 |
package io.oxygenhub.android.ui.flow |
| 2 |
|
| 3 |
import androidx.compose.foundation.clickable |
| 4 |
import androidx.compose.foundation.horizontalScroll |
| 5 |
import androidx.compose.foundation.layout.Arrangement |
| 6 |
import androidx.compose.foundation.layout.Column |
| 7 |
import androidx.compose.foundation.layout.Row |
| 8 |
import androidx.compose.foundation.layout.fillMaxSize |
| 9 |
import androidx.compose.foundation.layout.fillMaxWidth |
| 10 |
import androidx.compose.foundation.layout.padding |
| 11 |
import androidx.compose.foundation.layout.width |
| 12 |
import androidx.compose.foundation.lazy.LazyColumn |
| 13 |
import androidx.compose.foundation.lazy.items |
| 14 |
import androidx.compose.foundation.lazy.rememberLazyListState |
| 15 |
import androidx.compose.foundation.rememberScrollState |
| 16 |
import androidx.compose.foundation.text.selection.SelectionContainer |
| 17 |
import androidx.compose.material.icons.Icons |
| 18 |
import androidx.compose.material.icons.outlined.Block |
| 19 |
import androidx.compose.material.icons.outlined.CheckCircle |
| 20 |
import androidx.compose.material.icons.outlined.Error |
| 21 |
import androidx.compose.material.icons.outlined.HourglassEmpty |
| 22 |
import androidx.compose.material.icons.outlined.PlayCircle |
| 23 |
import androidx.compose.material.icons.outlined.RemoveCircleOutline |
| 24 |
import androidx.compose.material.icons.outlined.Replay |
| 25 |
import androidx.compose.material.icons.outlined.RocketLaunch |
| 26 |
import androidx.compose.material3.HorizontalDivider |
| 27 |
import androidx.compose.material3.Icon |
| 28 |
import androidx.compose.material3.IconButton |
| 29 |
import androidx.compose.material3.ListItem |
| 30 |
import androidx.compose.material3.MaterialTheme |
| 31 |
import androidx.compose.material3.Text |
| 32 |
import androidx.compose.runtime.Composable |
| 33 |
import androidx.compose.runtime.LaunchedEffect |
| 34 |
import androidx.compose.runtime.getValue |
| 35 |
import androidx.compose.ui.Modifier |
| 36 |
import androidx.compose.ui.graphics.Color |
| 37 |
import androidx.compose.ui.graphics.vector.ImageVector |
| 38 |
import androidx.compose.ui.text.font.FontFamily |
| 39 |
import androidx.compose.ui.unit.dp |
| 40 |
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 41 |
import io.oxygenhub.android.api.FlowJob |
| 42 |
import io.oxygenhub.android.api.FlowRun |
| 43 |
import io.oxygenhub.android.ui.common.AppScreen |
| 44 |
import io.oxygenhub.android.ui.common.EmptyState |
| 45 |
import io.oxygenhub.android.ui.common.ErrorState |
| 46 |
import io.oxygenhub.android.ui.common.Load |
| 47 |
import io.oxygenhub.android.ui.common.LoadContent |
| 48 |
import io.oxygenhub.android.ui.common.LoadingState |
| 49 |
import io.oxygenhub.android.ui.common.relativeTime |
| 50 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 51 |
|
| 52 |
/** A status's icon and colour, as the hub's run pages show them. */ |
| 53 |
@Composable |
| 54 |
fun statusLook(status: String): Pair<ImageVector, Color> { |
| 55 |
val c = LocalStatusColors.current |
| 56 |
return when (status) { |
| 57 |
"passed" -> Icons.Outlined.CheckCircle to c.ok |
| 58 |
"failed", "error" -> Icons.Outlined.Error to c.bad |
| 59 |
"running" -> Icons.Outlined.PlayCircle to c.warn |
| 60 |
"cancelled" -> Icons.Outlined.Block to MaterialTheme.colorScheme.onSurfaceVariant |
| 61 |
"skipped" -> Icons.Outlined.RemoveCircleOutline to MaterialTheme.colorScheme.onSurfaceVariant |
| 62 |
else -> Icons.Outlined.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant |
| 63 |
} |
| 64 |
} |
| 65 |
|
| 66 |
/** A repository's runs, for its tab. */ |
| 67 |
@Composable |
| 68 |
fun RunList(vm: RunsViewModel, onOpen: (FlowRun) -> Unit) { |
| 69 |
val state by vm.state.collectAsStateWithLifecycle() |
| 70 |
LoadContent(state, onRefresh = vm::refresh) { runs -> |
| 71 |
if (runs.isEmpty()) { |
| 72 |
EmptyState(Icons.Outlined.RocketLaunch, "No runs yet", "A push to ${vm.owner}/${vm.name} with a .flow/pipeline.kdl starts one.") |
| 73 |
} else { |
| 74 |
LazyColumn(Modifier.fillMaxSize()) { |
| 75 |
items(runs, key = { it.number }) { r -> |
| 76 |
val (icon, colour) = statusLook(r.status) |
| 77 |
ListItem( |
| 78 |
modifier = Modifier.clickable { onOpen(r) }, |
| 79 |
leadingContent = { Icon(icon, contentDescription = r.status, tint = colour) }, |
| 80 |
headlineContent = { Text("#${r.number} ${r.bookmark}") }, |
| 81 |
supportingContent = { |
| 82 |
Text( |
| 83 |
listOfNotNull( |
| 84 |
r.status, |
| 85 |
"${r.trigger} by ${r.actor}", |
| 86 |
relativeTime(r.createdMs).takeIf { it.isNotEmpty() }, |
| 87 |
r.queuePosition?.let { "#$it in the queue" }, |
| 88 |
).joinToString(" · "), |
| 89 |
) |
| 90 |
}, |
| 91 |
) |
| 92 |
HorizontalDivider() |
| 93 |
} |
| 94 |
} |
| 95 |
} |
| 96 |
} |
| 97 |
} |
| 98 |
|
| 99 |
/** A run: its jobs, refreshed while it is live; rerun or cancel it. */ |
| 100 |
@Composable |
| 101 |
fun RunScreen(vm: RunViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenJob: (FlowJob) -> Unit) { |
| 102 |
val state by vm.state.collectAsStateWithLifecycle() |
| 103 |
val error by vm.error.collectAsStateWithLifecycle() |
| 104 |
val run = (state as? Load.Ready)?.value |
| 105 |
AppScreen( |
| 106 |
title = "Run #${vm.number}", |
| 107 |
subtitle = listOfNotNull("${vm.owner}/${vm.name}", run?.bookmark, run?.status).joinToString(" · "), |
| 108 |
onBack = onBack, |
| 109 |
actions = { |
| 110 |
if (canWrite && run != null) { |
| 111 |
if (run.live) { |
| 112 |
IconButton(onClick = vm::cancel) { Icon(Icons.Outlined.Block, contentDescription = "Cancel the run") } |
| 113 |
} else { |
| 114 |
IconButton(onClick = vm::rerun) { Icon(Icons.Outlined.Replay, contentDescription = "Run it again") } |
| 115 |
} |
| 116 |
} |
| 117 |
}, |
| 118 |
) { |
| 119 |
LoadContent(state, onRefresh = vm::refresh) { r -> |
| 120 |
LazyColumn(Modifier.fillMaxSize()) { |
| 121 |
if (r.error.isNotEmpty()) item { Text(r.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } |
| 122 |
error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } } |
| 123 |
items(r.jobs, key = { it.name }) { j -> |
| 124 |
val (icon, colour) = statusLook(j.status) |
| 125 |
ListItem( |
| 126 |
modifier = Modifier.clickable { onOpenJob(j) }, |
| 127 |
leadingContent = { Icon(icon, contentDescription = j.status, tint = colour) }, |
| 128 |
headlineContent = { Text(j.name) }, |
| 129 |
supportingContent = { |
| 130 |
Column { |
| 131 |
Text( |
| 132 |
listOfNotNull( |
| 133 |
j.status, |
| 134 |
j.seconds()?.let { "$it s" }, |
| 135 |
j.runner.takeIf { it.isNotEmpty() }?.let { "on $it" }, |
| 136 |
j.exitCode?.let { "exit $it" }, |
| 137 |
).joinToString(" · "), |
| 138 |
) |
| 139 |
if (j.waiting.isNotEmpty()) Text(j.waiting, style = MaterialTheme.typography.bodySmall) |
| 140 |
if (j.error.isNotEmpty()) Text(j.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) |
| 141 |
} |
| 142 |
}, |
| 143 |
trailingContent = if (canWrite && !j.live && !r.live) { |
| 144 |
{ IconButton(onClick = { vm.rerunJob(j.name) }) { Icon(Icons.Outlined.Replay, contentDescription = "Run ${j.name} again") } } |
| 145 |
} else { |
| 146 |
null |
| 147 |
}, |
| 148 |
) |
| 149 |
HorizontalDivider() |
| 150 |
} |
| 151 |
} |
| 152 |
} |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
/** A job's log, following new lines while it runs. */ |
| 157 |
@Composable |
| 158 |
fun JobLogScreen(vm: JobLogViewModel, onBack: () -> Unit) { |
| 159 |
val state by vm.state.collectAsStateWithLifecycle() |
| 160 |
val list = rememberLazyListState() |
| 161 |
val status = LocalStatusColors.current |
| 162 |
val log = (state as? Load.Ready)?.value |
| 163 |
// Follow the end while the job runs. |
| 164 |
LaunchedEffect(log?.lines?.size) { |
| 165 |
if (log != null && log.live && log.lines.isNotEmpty()) list.scrollToItem(log.lines.size - 1) |
| 166 |
} |
| 167 |
AppScreen(title = vm.job, subtitle = listOfNotNull("${vm.owner}/${vm.name} #${vm.number}", log?.status).joinToString(" · "), onBack = onBack) { |
| 168 |
when (val s = state) { |
| 169 |
Load.Loading -> LoadingState() |
| 170 |
is Load.Failed -> ErrorState(s.message, onRetry = vm::retry) |
| 171 |
is Load.Ready -> SelectionContainer { |
| 172 |
LazyColumn(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()), state = list) { |
| 173 |
items(s.value.lines, key = { it.seq }) { l -> |
| 174 |
Row(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 175 |
Text( |
| 176 |
"%.1fs".format(l.t / 1000.0), |
| 177 |
modifier = Modifier.width(56.dp), |
| 178 |
fontFamily = FontFamily.Monospace, |
| 179 |
style = MaterialTheme.typography.bodySmall, |
| 180 |
color = MaterialTheme.colorScheme.outline, |
| 181 |
) |
| 182 |
Text( |
| 183 |
l.text, |
| 184 |
fontFamily = FontFamily.Monospace, |
| 185 |
style = MaterialTheme.typography.bodySmall, |
| 186 |
softWrap = false, |
| 187 |
color = when (l.stream) { |
| 188 |
"stderr" -> status.bad |
| 189 |
"flow" -> MaterialTheme.colorScheme.primary |
| 190 |
else -> MaterialTheme.colorScheme.onSurface |
| 191 |
}, |
| 192 |
) |
| 193 |
} |
| 194 |
} |
| 195 |
if (s.value.live) item { Text("…", modifier = Modifier.fillMaxWidth().padding(8.dp)) } |
| 196 |
} |
| 197 |
} |
| 198 |
} |
| 199 |
} |
| 200 |
} |