OXYGENHUB

Flow on the phone: a repository's runs, a run's jobs, live logs, rerun and cancel (refs #5) 1c42a70fcba3

A Runs tab lists a repository's runs; a run shows its jobs (where each
ran, how long, why one waits, its error), fetched again every few
seconds while it is live, with Cancel, Run again, and run one job again;
a job's log follows new lines while it runs, stderr and the runner's own
lines in their colours.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5f80633a1084
When
· 2026-10-25 22:47 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1c42a70fcba321e9bc60046152644cbe1ee5b7330bebf04c6d8889ce991d0f92
Depends on
144cbf7bf2b5 31e4f3755627 4aee5293461d 57c0824300db 5ae4af48ddd2 98dbd1220929 e2df27a81e5d e833eb0a9e7f
Depended on by
821035db5a1c 6e2af63ae616 1dfa631436b6 0ca942a9a1e4 ca672a3ada1f 62429abe5869 6fcaca2bd500 dfbc3e972872
In bookmarks
main
In tags
v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
23 atoms, 4 tree operations, 8 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/flow/FlowScreens.kt +200 −0
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 +
}
added app/src/main/java/io/oxygenhub/android/ui/flow/FlowViewModels.kt +149 −0
1 +
package io.oxygenhub.android.ui.flow
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import androidx.lifecycle.viewModelScope
5 +
import io.oxygenhub.android.api.FlowRun
6 +
import io.oxygenhub.android.api.HubApi
7 +
import io.oxygenhub.android.api.LogLine
8 +
import io.oxygenhub.android.api.userMessage
9 +
import io.oxygenhub.android.ui.common.Load
10 +
import io.oxygenhub.android.ui.common.load
11 +
import kotlinx.coroutines.CancellationException
12 +
import kotlinx.coroutines.Job
13 +
import kotlinx.coroutines.delay
14 +
import kotlinx.coroutines.flow.MutableStateFlow
15 +
import kotlinx.coroutines.flow.StateFlow
16 +
import kotlinx.coroutines.flow.asStateFlow
17 +
import kotlinx.coroutines.isActive
18 +
import kotlinx.coroutines.launch
19 +
20 +
/** A repository's runs, newest first (#5). */
21 +
class RunsViewModel(private val api: HubApi, val owner: String, val name: String) : ViewModel() {
22 +
    private val _state = MutableStateFlow<Load<List<FlowRun>>>(Load.Loading)
23 +
    val state: StateFlow<Load<List<FlowRun>>> = _state.asStateFlow()
24 +
    private var job: Job? = null
25 +
26 +
    init {
27 +
        refresh()
28 +
    }
29 +
30 +
    fun refresh() {
31 +
        job = load(_state, job) { api.runs(owner, name) }
32 +
    }
33 +
}
34 +
35 +
/**
36 +
 * One run and its jobs, fetched again every few seconds while it is queued
37 +
 * or running, so its jobs' states follow; rerun and cancel it.
38 +
 */
39 +
class RunViewModel(
40 +
    private val api: HubApi,
41 +
    val owner: String,
42 +
    val name: String,
43 +
    val number: Long,
44 +
    /** How often a live run is fetched again; 0 not at all (tests). */
45 +
    private val pollMs: Long = 3000,
46 +
) : ViewModel() {
47 +
    private val _state = MutableStateFlow<Load<FlowRun>>(Load.Loading)
48 +
    val state: StateFlow<Load<FlowRun>> = _state.asStateFlow()
49 +
50 +
    private val _error = MutableStateFlow<String?>(null)
51 +
    val error: StateFlow<String?> = _error.asStateFlow()
52 +
53 +
    private var job: Job? = null
54 +
55 +
    init {
56 +
        refresh()
57 +
        if (pollMs > 0) {
58 +
            viewModelScope.launch {
59 +
                while (isActive) {
60 +
                    delay(pollMs)
61 +
                    if ((_state.value as? Load.Ready)?.value?.live == true) poll()
62 +
                }
63 +
            }
64 +
        }
65 +
    }
66 +
67 +
    fun refresh() {
68 +
        job = load(_state, job) { api.run(owner, name, number) }
69 +
    }
70 +
71 +
    /** Fetch it again without a spinner. */
72 +
    suspend fun poll() {
73 +
        try {
74 +
            _state.value = Load.Ready(api.run(owner, name, number))
75 +
        } catch (e: CancellationException) {
76 +
            throw e
77 +
        } catch (_: Exception) {
78 +
            // The next tick tries again; the last state stays shown.
79 +
        }
80 +
    }
81 +
82 +
    fun rerun() = act { api.rerunRun(owner, name, number) }
83 +
84 +
    fun rerunJob(job: String) = act { api.rerunJob(owner, name, number, job) }
85 +
86 +
    fun cancel() = act { api.cancelRun(owner, name, number) }
87 +
88 +
    private fun act(step: suspend () -> Unit) {
89 +
        _error.value = null
90 +
        viewModelScope.launch {
91 +
            try {
92 +
                step()
93 +
                poll()
94 +
            } catch (e: CancellationException) {
95 +
                throw e
96 +
            } catch (e: Exception) {
97 +
                _error.value = e.userMessage()
98 +
            }
99 +
        }
100 +
    }
101 +
}
102 +
103 +
/**
104 +
 * A job's log, followed while the job runs: new lines are asked for after
105 +
 * the last one held, every couple of seconds, until the job has finished.
106 +
 */
107 +
class JobLogViewModel(
108 +
    private val api: HubApi,
109 +
    val owner: String,
110 +
    val name: String,
111 +
    val number: Long,
112 +
    val job: String,
113 +
    private val pollMs: Long = 2000,
114 +
) : ViewModel() {
115 +
    data class Log(val lines: List<LogLine>, val status: String, val live: Boolean)
116 +
117 +
    private val _state = MutableStateFlow<Load<Log>>(Load.Loading)
118 +
    val state: StateFlow<Load<Log>> = _state.asStateFlow()
119 +
120 +
    init {
121 +
        viewModelScope.launch {
122 +
            next()
123 +
            while (pollMs > 0 && isActive && (_state.value as? Load.Ready)?.value?.live != false) {
124 +
                delay(pollMs)
125 +
                next()
126 +
            }
127 +
        }
128 +
    }
129 +
130 +
    /** The lines after the last one held, and whether the job still runs. */
131 +
    suspend fun next() {
132 +
        val held = (_state.value as? Load.Ready)?.value
133 +
        try {
134 +
            val after = held?.lines?.lastOrNull()?.seq ?: 0
135 +
            val more = api.jobLog(owner, name, number, job, after)
136 +
            val j = api.run(owner, name, number).jobs.firstOrNull { it.name == job }
137 +
            _state.value = Load.Ready(Log((held?.lines.orEmpty()) + more, j?.status ?: held?.status.orEmpty(), j?.live ?: false))
138 +
        } catch (e: CancellationException) {
139 +
            throw e
140 +
        } catch (e: Exception) {
141 +
            if (held == null) _state.value = Load.failed(e)
142 +
        }
143 +
    }
144 +
145 +
    fun retry() {
146 +
        _state.value = Load.Loading
147 +
        viewModelScope.launch { next() }
148 +
    }
149 +
}
added app/src/test/java/io/oxygenhub/android/ui/FlowViewModelsTest.kt +64 −0
1 +
package io.oxygenhub.android.ui
2 +
3 +
import io.oxygenhub.android.FakeHubApi
4 +
import io.oxygenhub.android.MainDispatcherRule
5 +
import io.oxygenhub.android.api.FlowJob
6 +
import io.oxygenhub.android.api.FlowRun
7 +
import io.oxygenhub.android.api.LogLine
8 +
import io.oxygenhub.android.ui.common.Load
9 +
import io.oxygenhub.android.ui.flow.JobLogViewModel
10 +
import io.oxygenhub.android.ui.flow.RunViewModel
11 +
import io.oxygenhub.android.ui.flow.RunsViewModel
12 +
import kotlinx.coroutines.test.runTest
13 +
import org.junit.Assert.assertEquals
14 +
import org.junit.Assert.assertFalse
15 +
import org.junit.Assert.assertTrue
16 +
import org.junit.Rule
17 +
import org.junit.Test
18 +
19 +
class FlowViewModelsTest {
20 +
    @get:Rule val main = MainDispatcherRule()
21 +
22 +
    private val api = FakeHubApi().apply {
23 +
        runList = listOf(FlowRun(56, "main", "passed"), FlowRun(55, "main", "failed"))
24 +
        runStore[56] = FlowRun(56, "main", "running", jobs = listOf(FlowJob("count", "running", runner = "android-pixel-10-pro-fold")))
25 +
        logs["count"] = mutableListOf(LogLine(1, 0, "flow", "fetching"), LogLine(2, 1800, "stdout", "step 1 of 120"))
26 +
    }
27 +
28 +
    @Test
29 +
    fun listsTheRepositorysRuns() {
30 +
        val runs = (RunsViewModel(api, "andymccall", "phone-runner-demo").state.value as Load.Ready).value
31 +
        assertEquals(listOf(56L, 55L), runs.map { it.number })
32 +
    }
33 +
34 +
    @Test
35 +
    fun aRunFollowsItsJobsAndCanBeCancelled() = runTest {
36 +
        val vm = RunViewModel(api, "andymccall", "phone-runner-demo", 56, pollMs = 0)
37 +
        assertTrue((vm.state.value as Load.Ready).value.live)
38 +
        api.runStore[56] = api.runStore.getValue(56).copy(status = "passed", jobs = listOf(FlowJob("count", "passed", runner = "plex-1")))
39 +
        vm.poll()
40 +
        assertEquals("plex-1", (vm.state.value as Load.Ready).value.jobs[0].runner)
41 +
        vm.cancel()
42 +
        assertTrue("cancel 56" in api.calls)
43 +
        vm.rerunJob("count")
44 +
        assertTrue("rerun 56 count" in api.calls)
45 +
    }
46 +
47 +
    @Test
48 +
    fun aLogGrowsFromTheLastLineHeldUntilTheJobEnds() = runTest {
49 +
        val vm = JobLogViewModel(api, "andymccall", "phone-runner-demo", 56, "count", pollMs = 0)
50 +
        var log = (vm.state.value as Load.Ready).value
51 +
        assertEquals(listOf("fetching", "step 1 of 120"), log.lines.map { it.text })
52 +
        assertTrue(log.live)
53 +
        api.logs.getValue("count") += LogLine(3, 2500, "flow", "── handed back by android-pixel-10-pro-fold: off Wi-Fi")
54 +
        api.runStore[56] = api.runStore.getValue(56).copy(status = "running", jobs = listOf(FlowJob("count", "queued")))
55 +
        vm.next()
56 +
        log = (vm.state.value as Load.Ready).value
57 +
        assertEquals(3, log.lines.size)
58 +
        assertTrue("log count after 2" in api.calls)
59 +
        api.runStore[56] = api.runStore.getValue(56).copy(status = "passed", jobs = listOf(FlowJob("count", "passed")))
60 +
        vm.next()
61 +
        assertFalse((vm.state.value as Load.Ready).value.live)
62 +
        assertEquals("passed", (vm.state.value as Load.Ready).value.status)
63 +
    }
64 +
}
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +40 −0
…
68 unchanged lines
69 69
70 70
    suspend fun issue(owner: String, name: String, number: Long): Issue
71 71
72 +
    /** A repository's runs, newest first. */
73 +
    suspend fun runs(owner: String, name: String): List<FlowRun>
74 +
75 +
    /** One run, with its jobs. */
76 +
    suspend fun run(owner: String, name: String, number: Long): FlowRun
77 +
78 +
    /** A job's log lines after [after] (0: from the start). */
79 +
    suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine>
80 +
81 +
    suspend fun rerunRun(owner: String, name: String, number: Long)
82 +
83 +
    /** Run [job] again in the same run, with the jobs that depend on it. */
84 +
    suspend fun rerunJob(owner: String, name: String, number: Long, job: String)
85 +
86 +
    suspend fun cancelRun(owner: String, name: String, number: Long)
87 +
72 88
    /** [owner]'s project boards the caller can see. */
73 89
    suspend fun projects(owner: String): List<Project>
74 90
…
63 unchanged lines
138 154
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
139 155
140 156
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
157 +
158 +
    override suspend fun runs(owner: String, name: String): List<FlowRun> =
159 +
        get(url("repos", owner, name, "flow", "runs"), ListSerializer(FlowRun.serializer()))
160 +
161 +
    override suspend fun run(owner: String, name: String, number: Long): FlowRun =
162 +
        get(url("repos", owner, name, "flow", "runs", number.toString()), FlowRun.serializer())
163 +
164 +
    override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> =
165 +
        get(
166 +
            url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "log") { addQueryParameter("after", after.toString()) },
167 +
            ListSerializer(LogLine.serializer()),
168 +
        )
169 +
170 +
    override suspend fun rerunRun(owner: String, name: String, number: Long) {
171 +
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "rerun"))
172 +
    }
173 +
174 +
    override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) {
175 +
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "rerun"))
176 +
    }
177 +
178 +
    override suspend fun cancelRun(owner: String, name: String, number: Long) {
179 +
        send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "cancel"))
180 +
    }
141 181
142 182
    override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer()))
143 183
…
192 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +44 −0
…
76 unchanged lines
77 77
    val isOpen: Boolean get() = state == "open"
78 78
}
79 79
80 +
/** A Flow run of a repository's pipeline (#5). */
81 +
@Serializable
82 +
data class FlowRun(
83 +
    val number: Long,
84 +
    val bookmark: String = "",
85 +
    /** `queued`, `running`, `passed`, `failed`, `error` or `cancelled`. */
86 +
    val status: String = "queued",
87 +
    /** `push`, `manual`, `schedule`, `proposal` or `rerun:N`. */
88 +
    val trigger: String = "",
89 +
    val actor: String = "",
90 +
    val error: String = "",
91 +
    @SerialName("created_ms") val createdMs: Long = 0,
92 +
    @SerialName("finished_ms") val finishedMs: Long? = null,
93 +
    val url: String = "",
94 +
    @SerialName("queue_position") val queuePosition: Long? = null,
95 +
    val jobs: List<FlowJob> = emptyList(),
96 +
) {
97 +
    val live: Boolean get() = status == "queued" || status == "running"
98 +
}
99 +
100 +
/** A job of a run. */
101 +
@Serializable
102 +
data class FlowJob(
103 +
    val name: String,
104 +
    val status: String = "queued",
105 +
    @SerialName("depends_on") val dependsOn: List<String> = emptyList(),
106 +
    val runner: String = "",
107 +
    @SerialName("exit_code") val exitCode: Long? = null,
108 +
    val error: String = "",
109 +
    @SerialName("started_ms") val startedMs: Long? = null,
110 +
    @SerialName("finished_ms") val finishedMs: Long? = null,
111 +
    @SerialName("log_lines") val logLines: Long = 0,
112 +
    val waiting: String = "",
113 +
) {
114 +
    val live: Boolean get() = status == "queued" || status == "running"
115 +
116 +
    /** How long it ran, or has been running, in seconds. */
117 +
    fun seconds(now: Long = System.currentTimeMillis()): Long? = startedMs?.let { ((finishedMs ?: now) - it) / 1000 }
118 +
}
119 +
120 +
/** A line of a job's log: `stdout`, `stderr` or `flow` (the runner's own). */
121 +
@Serializable
122 +
data class LogLine(val seq: Long, val t: Long = 0, val stream: String = "stdout", val text: String = "")
123 +
80 124
/** A project board's summary (`GET /projects/{owner}`). */
81 125
@Serializable
82 126
data class Project(
…
286 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +27 −0
…
47 unchanged lines
48 48
import io.oxygenhub.android.ui.common.rememberAvatar
49 49
import io.oxygenhub.android.ui.dashboard.DashboardScreen
50 50
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
51 +
import io.oxygenhub.android.ui.flow.JobLogScreen
52 +
import io.oxygenhub.android.ui.flow.JobLogViewModel
53 +
import io.oxygenhub.android.ui.flow.RunScreen
54 +
import io.oxygenhub.android.ui.flow.RunViewModel
55 +
import io.oxygenhub.android.ui.flow.RunsViewModel
51 56
import io.oxygenhub.android.ui.inbox.InboxScreen
52 57
import io.oxygenhub.android.ui.inbox.InboxViewModel
53 58
import io.oxygenhub.android.ui.issues.IssueScreen
…
51 unchanged lines
105 110
106 111
@Serializable data class NewIssueRoute(val owner: String, val name: String)
107 112
113 +
/** A Flow run, and a job's log (#5). */
114 +
@Serializable data class RunRoute(val owner: String, val name: String, val number: Long)
115 +
116 +
@Serializable data class JobLogRoute(val owner: String, val name: String, val number: Long, val job: String)
117 +
108 118
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
109 119
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
110 120
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
…
71 unchanged lines
182 192
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
183 193
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
184 194
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
195 +
                        rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) },
185 196
                        onBack,
186 197
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
187 198
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
188 199
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
189 200
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
190 201
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
202 +
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
191 203
                    )
192 204
                }
193 205
            }
…
3 unchanged lines
197 209
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
198 210
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
199 211
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
212 +
                    rememberViewModel { RunsViewModel(api, r.owner, r.name) },
200 213
                    onBack = { nav.popBackStack() },
201 214
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
202 215
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
203 216
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
204 217
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
205 218
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
219 +
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
206 220
                )
207 221
            }
208 222
            composable<ProjectsRoute> {
…
11 unchanged lines
220 234
                        }
221 235
                    },
222 236
                )
237 +
            }
238 +
            composable<RunRoute> { e ->
239 +
                val r = e.toRoute<RunRoute>()
240 +
                RunScreen(
241 +
                    rememberViewModel { RunViewModel(api, r.owner, r.name, r.number) },
242 +
                    canWrite = session.canWrite,
243 +
                    onBack = { nav.popBackStack() },
244 +
                    onOpenJob = { j -> nav.navigate(JobLogRoute(r.owner, r.name, r.number, j.name)) },
245 +
                )
246 +
            }
247 +
            composable<JobLogRoute> { e ->
248 +
                val r = e.toRoute<JobLogRoute>()
249 +
                JobLogScreen(rememberViewModel { JobLogViewModel(api, r.owner, r.name, r.number, r.job) }, onBack = { nav.popBackStack() })
223 250
            }
224 251
            composable<IssueRoute> { e ->
225 252
                val r = e.toRoute<IssueRoute>()
…
65 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +10 −4
…
1 unchanged lines
2 2
3 3
import androidx.compose.foundation.layout.Column
4 4
import androidx.compose.foundation.layout.fillMaxSize
5 -
import androidx.compose.material3.PrimaryTabRow
5 +
import androidx.compose.material3.PrimaryScrollableTabRow
6 6
import androidx.compose.material3.Tab
7 7
import androidx.compose.material3.Text
8 8
import androidx.compose.runtime.Composable
…
2 unchanged lines
11 11
import androidx.compose.runtime.saveable.rememberSaveable
12 12
import androidx.compose.runtime.setValue
13 13
import androidx.compose.ui.Modifier
14 +
import androidx.compose.ui.unit.dp
14 15
import io.oxygenhub.android.api.Proposal
15 16
import io.oxygenhub.android.ui.code.CodeBrowser
16 17
import io.oxygenhub.android.ui.code.CodeViewModel
18 +
import io.oxygenhub.android.ui.flow.RunList
19 +
import io.oxygenhub.android.ui.flow.RunsViewModel
17 20
import io.oxygenhub.android.ui.common.AppScreen
18 21
import io.oxygenhub.android.ui.issues.IssueList
19 22
import io.oxygenhub.android.ui.issues.IssuesViewModel
20 23
import io.oxygenhub.android.ui.proposals.ProposalList
21 24
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
22 25
23 -
/** A repository's tabs: its code, its issues and its proposals (#3). */
24 -
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals") }
26 +
/** A repository's tabs: its code, issues, proposals and Flow runs (#3, #5). */
27 +
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals"), Runs("Runs") }
25 28
26 29
@Composable
27 30
fun RepositoryScreen(
28 31
    code: CodeViewModel,
29 32
    issues: IssuesViewModel,
30 33
    proposals: ProposalsViewModel,
34 +
    runs: RunsViewModel,
31 35
    onBack: (() -> Unit)?,
32 36
    onOpenProposal: (Proposal) -> Unit,
33 37
    onOpenFile: (ref: String, path: String) -> Unit,
34 38
    onOpenHistory: (ref: String) -> Unit,
35 39
    onOpenIssue: (Long) -> Unit,
36 40
    onNewIssue: (() -> Unit)?,
41 +
    onOpenRun: (Long) -> Unit,
37 42
    initialTab: RepositoryTab = RepositoryTab.Code,
38 43
) {
39 44
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
40 45
    AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) {
41 46
        Column(Modifier.fillMaxSize()) {
42 -
            PrimaryTabRow(selectedTabIndex = tab) {
47 +
            PrimaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) {
43 48
                RepositoryTab.entries.forEach { t ->
44 49
                    Tab(selected = tab == t.ordinal, onClick = { tab = t.ordinal }, text = { Text(t.label) })
45 50
                }
…
2 unchanged lines
48 53
                RepositoryTab.Code -> CodeBrowser(code, onOpenFile, onOpenHistory)
49 54
                RepositoryTab.Issues -> IssueList(issues, onOpenIssue, onNewIssue)
50 55
                RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal)
56 +
                RepositoryTab.Runs -> RunList(runs) { onOpenRun(it.number) }
51 57
            }
52 58
        }
53 59
    }
…
1 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +17 −0
…
58 unchanged lines
59 59
    }
60 60
    var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") }
61 61
    override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" }
62 +
    var runList: List<io.oxygenhub.android.api.FlowRun> = emptyList()
63 +
    override suspend fun runs(owner: String, name: String) = runList.also { calls += "runs" }
64 +
    val runStore = mutableMapOf<Long, io.oxygenhub.android.api.FlowRun>()
65 +
    override suspend fun run(owner: String, name: String, number: Long) = runStore.getValue(number).also { calls += "run $number" }
66 +
    val logs = mutableMapOf<String, MutableList<io.oxygenhub.android.api.LogLine>>()
67 +
    override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long) =
68 +
        logs[job].orEmpty().filter { it.seq > after }.also { calls += "log $job after $after" }
69 +
    override suspend fun rerunRun(owner: String, name: String, number: Long) {
70 +
        calls += "rerun $number"
71 +
    }
72 +
    override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) {
73 +
        calls += "rerun $number $job"
74 +
    }
75 +
    override suspend fun cancelRun(owner: String, name: String, number: Long) {
76 +
        calls += "cancel $number"
77 +
        runStore[number]?.let { runStore[number] = it.copy(status = "cancelled") }
78 +
    }
62 79
    var projectsOf: (String) -> List<io.oxygenhub.android.api.Project> = { emptyList() }
63 80
    override suspend fun projects(owner: String) = projectsOf(owner).also { calls += "projects $owner" }
64 81
    var boards = mutableMapOf<Long, io.oxygenhub.android.api.Board>()
…
81 unchanged lines