Pickable repo sorting, and collapsed CI log sections

001cc6a · Gabriel Morell · 2026-09-12 16:04

17 files +300 -70
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+7 −4
@@ -20,7 +20,7 @@ import androidx.navigation.compose.rememberNavController
20 20 import androidx.navigation.navArgument
21 21 import org.neiam.gitgud.app.auth.PairScreen
22 22 import org.neiam.gitgud.app.data.PairPayload
23 −import org.neiam.gitgud.app.data.ThemeStore
23 +import org.neiam.gitgud.app.data.PrefsStore
24 24 import org.neiam.gitgud.app.data.TokenStore
25 25 import org.neiam.gitgud.app.ui.IssueScreen
26 26 import org.neiam.gitgud.app.ui.LogScreen
@@ -49,12 +49,12 @@ class MainActivity : ComponentActivity() {
49 49 override fun onCreate(savedInstanceState: Bundle?) {
50 50 super.onCreate(savedInstanceState)
51 51 val tokens = TokenStore(this)
52 − val themeStore = ThemeStore(this)
52 + val prefs = PrefsStore(this)
53 53 val initialPair = parseIntent(intent)
54 54
55 55 setContent {
56 56 var theme by remember {
57 − mutableStateOf(themeStore.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
57 + mutableStateOf(prefs.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
58 58 }
59 59 GitGudTheme(theme = theme) {
60 60 Surface(
@@ -63,11 +63,12 @@ class MainActivity : ComponentActivity() {
63 63 ) {
64 64 GitGudApp(
65 65 tokens = tokens,
66 + prefs = prefs,
66 67 initialPair = initialPair,
67 68 currentTheme = theme,
68 69 onPickTheme = { picked ->
69 70 theme = picked
70 − themeStore.themeKey = picked.key
71 + prefs.themeKey = picked.key
71 72 },
72 73 )
73 74 }
@@ -84,6 +85,7 @@ class MainActivity : ComponentActivity() {
84 85 @Composable
85 86 fun GitGudApp(
86 87 tokens: TokenStore,
88 + prefs: PrefsStore,
87 89 initialPair: PairPayload?,
88 90 currentTheme: AppTheme,
89 91 onPickTheme: (AppTheme) -> Unit,
@@ -108,6 +110,7 @@ fun GitGudApp(
108 110 composable("repos") {
109 111 RepoListScreen(
110 112 tokens = tokens,
113 + prefs = prefs,
111 114 currentTheme = currentTheme,
112 115 onPickTheme = onPickTheme,
113 116 onOpenRepo = { owner, name -> nav.navigate("repo/$owner/$name") },
added android/app/src/main/java/org/neiam/gitgud/app/data/PrefsStore.kt
+51 −0
@@ -0,0 +1,51 @@
1 +package org.neiam.gitgud.app.data
2 +
3 +import android.content.Context
4 +import androidx.security.crypto.EncryptedSharedPreferences
5 +import androidx.security.crypto.MasterKey
6 +
7 +/**
8 + * Display preferences: the chosen theme and the repo list's sort order.
9 + *
10 + * A separate EncryptedSharedPreferences file from credentials, so
11 + * signing out doesn't reset how the app looks — re-pairing a device
12 + * shouldn't cost you the palette and ordering you'd settled on.
13 + *
14 + * Both are stored as their stable string keys rather than ordinals, so
15 + * reordering `ALL_THEMES` or `RepoSort` doesn't silently change what a
16 + * device had chosen. An unrecognized key falls back to the default
17 + * rather than failing.
18 + */
19 +class PrefsStore(context: Context) {
20 + private val prefs by lazy {
21 + val masterKey = MasterKey.Builder(context)
22 + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
23 + .build()
24 + EncryptedSharedPreferences.create(
25 + context,
26 + "gitgud_prefs",
27 + masterKey,
28 + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
29 + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
30 + )
31 + }
32 +
33 + var themeKey: String?
34 + get() = prefs.getString(KEY_THEME, null)
35 + set(value) = put(KEY_THEME, value)
36 +
37 + var repoSortKey: String?
38 + get() = prefs.getString(KEY_REPO_SORT, null)
39 + set(value) = put(KEY_REPO_SORT, value)
40 +
41 + private fun put(key: String, value: String?) {
42 + prefs.edit().also { e ->
43 + if (value == null) e.remove(key) else e.putString(key, value)
44 + }.apply()
45 + }
46 +
47 + companion object {
48 + private const val KEY_THEME = "theme_key"
49 + private const val KEY_REPO_SORT = "repo_sort"
50 + }
51 +}
added android/app/src/main/java/org/neiam/gitgud/app/data/RepoSort.kt
+74 −0
@@ -0,0 +1,74 @@
1 +package org.neiam.gitgud.app.data
2 +
3 +/**
4 + * How the repo list is ordered.
5 + *
6 + * Sorting happens on the client because `/repos` returns everything the
7 + * token can reach in one response — there's no pagination to respect,
8 + * so re-ordering is instant and costs no round-trip.
9 + *
10 + * `Pushed` is the default: the list's job is "what have I been working
11 + * on", and that's what recency answers.
12 + */
13 +enum class RepoSort(val key: String, val label: String) {
14 + Pushed("pushed", "Recently pushed"),
15 + Name("name", "Name"),
16 + Ci("ci", "CI status");
17 +
18 + fun sort(repos: List<Repository>): List<Repository> = when (this) {
19 + Pushed -> repos.sortedWith(byPushed)
20 + Name -> repos.sortedWith(byName)
21 + Ci -> repos.sortedWith(byCi)
22 + }
23 +
24 + companion object {
25 + val DEFAULT = Pushed
26 +
27 + fun byKey(key: String?): RepoSort =
28 + entries.firstOrNull { it.key == key } ?: DEFAULT
29 + }
30 +}
31 +
32 +private val byName = compareBy<Repository> { (it.full_name ?: it.name).lowercase() }
33 +
34 +/**
35 + * Newest push first, never-pushed repos last.
36 + *
37 + * Timestamps are compared as strings: they're ISO-8601 UTC emitted by
38 + * one server with one formatter, so lexicographic order *is*
39 + * chronological order, and there's no parse that can fail halfway
40 + * through sorting a list.
41 + *
42 + * An empty repo sorts to the bottom rather than the top, which is what
43 + * a null would do on a naive descending sort — it has no work on it to
44 + * be recent about.
45 + */
46 +private val byPushed = Comparator<Repository> { a, b ->
47 + val x = a.pushed_at
48 + val y = b.pushed_at
49 + when {
50 + x == y -> 0
51 + x == null -> 1
52 + y == null -> -1
53 + else -> y.compareTo(x)
54 + }
55 +}.then(byName)
56 +
57 +/**
58 + * Worst news first.
59 + *
60 + * Ordered by what wants a human, not by alphabet or severity in the
61 + * abstract: a failure is why you opened the app, something running is
62 + * worth watching, something queued is merely pending, and a cancelled
63 + * run was usually cancelled on purpose. Green and never-built sink.
64 + */
65 +private val byCi = compareBy<Repository> { ciRank(it.ci_status) }.then(byPushed)
66 +
67 +private fun ciRank(status: String?): Int = when (status) {
68 + "failure", "timed_out" -> 0
69 + "running" -> 1
70 + "queued", "blocked", "pending" -> 2
71 + "cancelled" -> 3
72 + "success" -> 4
73 + else -> 5
74 +}
deleted android/app/src/main/java/org/neiam/gitgud/app/data/ThemeStore.kt
+0 −35
@@ -1,35 +0,0 @@
1 −package org.neiam.gitgud.app.data
2 −
3 −import android.content.Context
4 −import androidx.security.crypto.EncryptedSharedPreferences
5 −import androidx.security.crypto.MasterKey
6 −
7 −/**
8 − * Persists the chosen theme key (an entry in `ALL_THEMES`). Stored in a
9 − * separate EncryptedSharedPreferences file from credentials so a token
10 − * reset doesn't clobber the theme.
11 − */
12 −class ThemeStore(context: Context) {
13 − private val prefs by lazy {
14 − val masterKey = MasterKey.Builder(context)
15 − .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
16 − .build()
17 − EncryptedSharedPreferences.create(
18 − context,
19 − "gitgud_prefs",
20 − masterKey,
21 − EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
22 − EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
23 − )
24 − }
25 −
26 − var themeKey: String?
27 − get() = prefs.getString(KEY_THEME, null)
28 − set(value) {
29 − prefs.edit().also { e ->
30 − if (value == null) e.remove(KEY_THEME) else e.putString(KEY_THEME, value)
31 − }.apply()
32 − }
33 −
34 − companion object { private const val KEY_THEME = "theme_key" }
35 −}
modified android/app/src/main/java/org/neiam/gitgud/app/ui/LogScreen.kt
+106 −29
@@ -1,11 +1,15 @@
1 1 package org.neiam.gitgud.app.ui
2 2
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.clickable
3 5 import androidx.compose.foundation.horizontalScroll
6 +import androidx.compose.foundation.layout.Box
4 7 import androidx.compose.foundation.layout.Column
5 8 import androidx.compose.foundation.layout.PaddingValues
6 9 import androidx.compose.foundation.layout.Row
7 10 import androidx.compose.foundation.layout.Spacer
8 11 import androidx.compose.foundation.layout.fillMaxSize
12 +import androidx.compose.foundation.layout.size
9 13 import androidx.compose.foundation.layout.fillMaxWidth
10 14 import androidx.compose.foundation.layout.padding
11 15 import androidx.compose.foundation.layout.width
@@ -14,7 +18,11 @@ import androidx.compose.foundation.lazy.items
14 18 import androidx.compose.foundation.rememberScrollState
15 19 import androidx.compose.material.icons.Icons
16 20 import androidx.compose.material.icons.automirrored.filled.ArrowBack
21 +import androidx.compose.material.icons.filled.ExpandLess
22 +import androidx.compose.material.icons.filled.ExpandMore
17 23 import androidx.compose.material.icons.filled.Refresh
24 +import androidx.compose.material.icons.filled.UnfoldLess
25 +import androidx.compose.material.icons.filled.UnfoldMore
18 26 import androidx.compose.material3.ExperimentalMaterial3Api
19 27 import androidx.compose.material3.HorizontalDivider
20 28 import androidx.compose.material3.Icon
@@ -32,17 +40,26 @@ import androidx.compose.runtime.remember
32 40 import androidx.compose.runtime.setValue
33 41 import androidx.compose.ui.Alignment
34 42 import androidx.compose.ui.Modifier
43 +import androidx.compose.ui.text.font.FontWeight
35 44 import androidx.compose.ui.text.style.TextOverflow
36 45 import androidx.compose.ui.unit.dp
37 46 import androidx.compose.ui.unit.sp
38 47 import kotlinx.coroutines.delay
39 48 import org.neiam.gitgud.app.data.GitGudClient
40 49 import org.neiam.gitgud.app.data.LogsResponse
50 +import org.neiam.gitgud.app.data.Step
41 51 import org.neiam.gitgud.app.data.TokenStore
42 52 import org.neiam.gitgud.app.ui.theme.B612
53 +import org.neiam.gitgud.app.ui.theme.LocalAppTheme
43 54
44 55 /**
45 − * A job's logs, grouped by step.
56 + * A job's logs, grouped by collapsible steps.
57 + *
58 + * Sections start collapsed. A green ten-step build is several thousand
59 + * lines of noise you didn't come for, and the step list itself is the
60 + * useful view — it says what ran and what broke, in one screen, without
61 + * scrolling. Failed steps are the exception and open themselves: that's
62 + * the step you opened the log to read.
46 63 *
47 64 * Lines don't wrap — they scroll sideways. Build output is written for
48 65 * an 80-column terminal, and wrapping a stack trace across a phone's
@@ -68,6 +85,12 @@ fun LogScreen(
68 85 var reloadKey by remember { mutableStateOf(0) }
69 86 val hScroll = rememberScrollState()
70 87
88 + // Only what the reader has explicitly toggled. Everything else
89 + // follows `defaultExpanded`, so a step that fails on a later poll
90 + // opens on its own rather than staying shut because it happened to
91 + // be passing when the screen first loaded.
92 + var overrides by remember(jobId) { mutableStateOf<Map<Long, Boolean>>(emptyMap()) }
93 +
71 94 LaunchedEffect(jobId, reloadKey) {
72 95 if (creds == null) {
73 96 error = "Not paired."
@@ -97,6 +120,20 @@ fun LogScreen(
97 120 }
98 121 },
99 122 actions = {
123 + val steps = logs?.steps.orEmpty().map { it.step }
124 + if (steps.isNotEmpty()) {
125 + val allOpen = steps.all { overrides[it.id] ?: defaultExpanded(it) }
126 + IconButton(
127 + onClick = {
128 + overrides = steps.associate { it.id to !allOpen }
129 + },
130 + ) {
131 + Icon(
132 + if (allOpen) Icons.Default.UnfoldLess else Icons.Default.UnfoldMore,
133 + contentDescription = if (allOpen) "Collapse all" else "Expand all",
134 + )
135 + }
136 + }
100 137 IconButton(onClick = { reloadKey++ }) {
101 138 Icon(Icons.Default.Refresh, contentDescription = "Refresh")
102 139 }
@@ -131,28 +168,42 @@ fun LogScreen(
131 168 }
132 169
133 170 l.steps.forEach { group ->
171 + val open = overrides[group.step.id] ?: defaultExpanded(group.step)
172 +
134 173 item(key = "head-${group.step.id}") {
135 174 StepHeader(
136 − name = group.step.name ?: "step ${group.step.number}",
137 − status = effectiveStatus(group.step.status, group.step.conclusion),
138 − duration = durationBetween(
139 − group.step.started_at,
140 − group.step.completed_at,
141 − ),
175 + step = group.step,
176 + lineCount = group.lines.size,
177 + expanded = open,
178 + onToggle = {
179 + overrides = overrides + (group.step.id to !open)
180 + },
142 181 )
143 182 }
183 +
184 + if (!open) return@forEach
185 +
144 186 items(group.lines, key = { "${group.step.id}-${it.seq}" }) { line ->
145 − Text(
146 − line.body,
147 − fontFamily = B612,
148 − fontSize = 11.sp,
149 − softWrap = false,
150 − maxLines = 1,
151 − overflow = TextOverflow.Clip,
187 + // The background sits on the Box, outside the
188 + // scroll, so it fills the viewport rather than
189 + // stopping at the end of a short line.
190 + Box(
152 191 modifier = Modifier
153 − .horizontalScroll(hScroll)
154 − .padding(horizontal = 10.dp, vertical = 1.dp),
155 − )
192 + .fillMaxWidth()
193 + .background(LocalAppTheme.current.cardBg),
194 + ) {
195 + Text(
196 + line.body,
197 + fontFamily = B612,
198 + fontSize = 11.sp,
199 + softWrap = false,
200 + maxLines = 1,
201 + overflow = TextOverflow.Clip,
202 + modifier = Modifier
203 + .horizontalScroll(hScroll)
204 + .padding(horizontal = 10.dp, vertical = 1.dp),
205 + )
206 + }
156 207 }
157 208 }
158 209 }
@@ -162,32 +213,58 @@ fun LogScreen(
162 213 }
163 214 }
164 215
216 +/**
217 + * A step opens by default only when it failed. `cancelled` doesn't
218 + * count — a cancelled step was usually cancelled on purpose, and its
219 + * output is the build stopping rather than a reason.
220 + */
221 +private fun defaultExpanded(step: Step): Boolean =
222 + effectiveStatus(step.status, step.conclusion) in setOf("failure", "timed_out")
223 +
165 224 @Composable
166 −private fun StepHeader(name: String, status: String?, duration: String) {
167 − Column {
225 +private fun StepHeader(
226 + step: Step,
227 + lineCount: Int,
228 + expanded: Boolean,
229 + onToggle: () -> Unit,
230 +) {
231 + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
168 232 HorizontalDivider()
169 233 Row(
170 234 verticalAlignment = Alignment.CenterVertically,
171 235 modifier = Modifier
172 236 .fillMaxWidth()
173 − .padding(horizontal = 10.dp, vertical = 6.dp),
237 + .clickable(onClick = onToggle)
238 + .padding(horizontal = 10.dp, vertical = 8.dp),
174 239 ) {
175 − StatusDot(status)
240 + StatusDot(effectiveStatus(step.status, step.conclusion))
176 241 Spacer(Modifier.width(8.dp))
177 242 Text(
178 − name,
243 + step.name ?: "step ${step.number}",
179 244 style = MaterialTheme.typography.labelMedium,
245 + fontWeight = FontWeight.Bold,
180 246 modifier = Modifier.weight(1f),
181 247 maxLines = 1,
182 248 overflow = TextOverflow.Ellipsis,
183 249 )
184 − if (duration.isNotBlank()) {
185 − Text(
186 − duration,
187 − style = MaterialTheme.typography.labelSmall,
188 − color = MaterialTheme.colorScheme.onSurfaceVariant,
189 − )
190 − }
250 +
251 + // A collapsed section still has to say what it's hiding,
252 + // or it reads as an empty step rather than a closed one.
253 + Text(
254 + listOfNotNull(
255 + durationBetween(step.started_at, step.completed_at).takeIf { it.isNotBlank() },
256 + "$lineCount ${if (lineCount == 1) "line" else "lines"}",
257 + ).joinToString(" · "),
258 + style = MaterialTheme.typography.labelSmall,
259 + color = MaterialTheme.colorScheme.onSurfaceVariant,
260 + )
261 + Spacer(Modifier.width(4.dp))
262 + Icon(
263 + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
264 + contentDescription = if (expanded) "Collapse" else "Expand",
265 + modifier = Modifier.size(16.dp),
266 + tint = MaterialTheme.colorScheme.onSurfaceVariant,
267 + )
191 268 }
192 269 }
193 270 }
modified android/app/src/main/java/org/neiam/gitgud/app/ui/RepoListScreen.kt
+62 −2
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout
21 21 import androidx.compose.material.icons.filled.Lock
22 22 import androidx.compose.material.icons.filled.MoreVert
23 23 import androidx.compose.material.icons.filled.Refresh
24 +import androidx.compose.material.icons.automirrored.filled.Sort
24 25 import androidx.compose.material3.AlertDialog
25 26 import androidx.compose.material3.Card
26 27 import androidx.compose.material3.DropdownMenu
@@ -51,6 +52,8 @@ import androidx.compose.ui.unit.dp
51 52 import kotlinx.coroutines.launch
52 53 import org.neiam.gitgud.app.data.GitGudClient
53 54 import org.neiam.gitgud.app.data.Namespace
55 +import org.neiam.gitgud.app.data.PrefsStore
56 +import org.neiam.gitgud.app.data.RepoSort
54 57 import org.neiam.gitgud.app.data.Repository
55 58 import org.neiam.gitgud.app.data.ReposResponse
56 59 import org.neiam.gitgud.app.data.TokenStore
@@ -71,6 +74,7 @@ import org.neiam.gitgud.app.ui.theme.AppTheme
71 74 @Composable
72 75 fun RepoListScreen(
73 76 tokens: TokenStore,
77 + prefs: PrefsStore,
74 78 currentTheme: AppTheme,
75 79 onPickTheme: (AppTheme) -> Unit,
76 80 onOpenRepo: (owner: String, name: String) -> Unit,
@@ -81,6 +85,8 @@ fun RepoListScreen(
81 85 var selectedNamespace by remember { mutableStateOf<String?>(null) }
82 86 var menuOpen by remember { mutableStateOf(false) }
83 87 var themePickerOpen by remember { mutableStateOf(false) }
88 + var sortPickerOpen by remember { mutableStateOf(false) }
89 + var sort by remember { mutableStateOf(RepoSort.byKey(prefs.repoSortKey)) }
84 90 var reloadKey by remember { mutableStateOf(0) }
85 91
86 92 LaunchedEffect(reloadKey) {
@@ -108,6 +114,14 @@ fun RepoListScreen(
108 114 Icon(Icons.Default.MoreVert, contentDescription = "Menu")
109 115 }
110 116 DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
117 + DropdownMenuItem(
118 + text = { Text("Sort: ${sort.label}") },
119 + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = null) },
120 + onClick = {
121 + menuOpen = false
122 + sortPickerOpen = true
123 + },
124 + )
111 125 DropdownMenuItem(
112 126 text = { Text("Theme: ${currentTheme.name}") },
113 127 onClick = {
@@ -134,8 +148,12 @@ fun RepoListScreen(
134 148 ReposState.Loading -> CenterSpinner(Modifier.padding(padding))
135 149 is ReposState.Error -> CenterText("Couldn't load repositories: ${s.message}", Modifier.padding(padding))
136 150 is ReposState.Loaded -> {
137 − val repos = s.data.repositories.filter {
138 − selectedNamespace == null || it.owner == selectedNamespace
151 + val repos = remember(s.data, selectedNamespace, sort) {
152 + sort.sort(
153 + s.data.repositories.filter {
154 + selectedNamespace == null || it.owner == selectedNamespace
155 + },
156 + )
139 157 }
140 158
141 159 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -164,6 +182,18 @@ fun RepoListScreen(
164 182 }
165 183 }
166 184
185 + if (sortPickerOpen) {
186 + SortPickerDialog(
187 + current = sort,
188 + onDismiss = { sortPickerOpen = false },
189 + onPick = { picked ->
190 + sortPickerOpen = false
191 + sort = picked
192 + prefs.repoSortKey = picked.key
193 + },
194 + )
195 + }
196 +
167 197 if (themePickerOpen) {
168 198 ThemePickerDialog(
169 199 current = currentTheme,
@@ -272,6 +302,36 @@ private fun RepoRow(repo: Repository, onClick: () -> Unit) {
272 302 }
273 303 }
274 304
305 +@Composable
306 +private fun SortPickerDialog(
307 + current: RepoSort,
308 + onDismiss: () -> Unit,
309 + onPick: (RepoSort) -> Unit,
310 +) {
311 + AlertDialog(
312 + onDismissRequest = onDismiss,
313 + title = { Text("Sort repositories by") },
314 + text = {
315 + Column {
316 + RepoSort.entries.forEach { option ->
317 + Row(
318 + verticalAlignment = Alignment.CenterVertically,
319 + modifier = Modifier
320 + .fillMaxWidth()
321 + .clickable { onPick(option) }
322 + .padding(vertical = 4.dp),
323 + ) {
324 + RadioButton(selected = option == current, onClick = { onPick(option) })
325 + Spacer(Modifier.width(8.dp))
326 + Text(option.label)
327 + }
328 + }
329 + }
330 + },
331 + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
332 + )
333 +}
334 +
275 335 @Composable
276 336 private fun ThemePickerDialog(
277 337 current: AppTheme,

Parents: 504e101