Diffs on a change, natively 0414607973cb
Each file a change touched shows its diff: context, removed and added lines with old and new numbers, tinted, wrapped, gaps marked. Small changes open every diff; larger ones start folded. From the hub's change API (hub #320). closes #21
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-26 00:34 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
0414607973cb51cea43c5263ce4a1290d49fa5b5d78958c9509e31b64a3b0cf3- Depends on
- 4aee5293461d 57c0824300db 5ae4af48ddd2 98dbd1220929
- Depended on by
- 40ab4df121e0 a49fd826e10b ed6c64d23fca
- 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
- 20 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Diffs on a change (#21):** each file a change touched shows its diff in the app: context, removed and added lines with their old and new numbers, tinted, long lines wrapped, gaps between hunks marked. A small change (up to five files, 400 lines) opens every diff; a larger one starts folded and a file's row opens it. The icon on the row still opens the file at the change. From the hub's change API (hub #320); an older hub sends no lines, and the row says so. |
|
| 5 | 6 | - **The phone as an OxygenFlow runner (#8, first part):** Account → OxygenFlow runner → *Offer this phone as a runner* asks the hub for a one-time token (`POST /api/v1/user/runner-token`) and registers the phone as a runner for your own repositories, labelled `android`, offering the WASM tier. The runner is OxygenFlow's own, built into the app through rust-core (OxygenFlow is a subrepo, `oxygenflow/`, and rust-core's oxy-core is the one it pins), running WASM modules in a thread with wasmtime. It runs in a foreground service with a notification, only while the phone is charging on Wi-Fi (an unmetered network); coming off charge or off Wi-Fi drains it (it takes nothing more and finishes its job). Start and Stop on the card. The app is larger: the runner and wasmtime are about 29 MB per architecture. |
|
| 6 | 7 | - **Proposals on the dashboard (#18):** Waiting for your review (open proposals by others in your repositories you have not reviewed) and Your proposals (your open ones and drafts), as the hub's dashboard lists them, each opening the proposal screen where it can be landed. From the hub's `GET /api/v1/user/proposals`; a hub without it shows neither. |
|
| 7 | 8 | - **Land proposals from the phone (#17):** a repository has Issues and Proposals tabs; proposals open (with drafts), landed, closed or all, each saying whether it is ready to land, what stands in the way, or that it will land when its checks pass. A proposal shows the landing card (Land into the target, after a confirmation; land when its checks pass, on or off; the hub's blockers in its own words, and its reason when it refuses), then its checks (failures first, each opening its run), sealed approvals and asked reviewers, its changes and description. Landing needs `repo:write`, which sign-in now asks for: a phone signed in before this reads only, and says so with a button to sign in again rather than failing. |
|
| … | 5 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +12 −0
| … | 208 unchanged lines |
||
| 209 | 209 | val from: String? = null, |
|
| 210 | 210 | val added: Int = 0, |
|
| 211 | 211 | val removed: Int = 0, |
|
| 212 | + | /** The diff with three lines of context (hub #320), cut at 2000 lines. */ |
|
| 213 | + | val lines: List<DiffLine> = emptyList(), |
|
| 214 | + | val truncated: Boolean = false, |
|
| 212 | 215 | ) |
|
| 213 | 216 | ||
| 217 | + | /** A line of a file's diff: `context`, `add`, `del`, or `gap` for lines left out between hunks. */ |
|
| 218 | + | @Serializable |
|
| 219 | + | data class DiffLine( |
|
| 220 | + | val kind: String = "context", |
|
| 221 | + | val old: Int? = null, |
|
| 222 | + | val new: Int? = null, |
|
| 223 | + | val text: String = "", |
|
| 224 | + | ) |
|
| 225 | + | ||
| 214 | 226 | /** One change, whole (`GET …/changes/{hash}`). */ |
|
| 215 | 227 | @Serializable |
|
| 216 | 228 | data class ChangeDetail( |
|
| … | 101 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/code/CodeScreens.kt +94 −14
| 1 | 1 | package io.oxygenhub.android.ui.code |
|
| 2 | 2 | ||
| 3 | + | import androidx.compose.foundation.background |
|
| 3 | 4 | import androidx.compose.foundation.clickable |
|
| 4 | 5 | import androidx.compose.foundation.horizontalScroll |
|
| 5 | 6 | import androidx.compose.foundation.isSystemInDarkTheme |
|
| … | 10 unchanged lines |
||
| 16 | 17 | import androidx.compose.foundation.rememberScrollState |
|
| 17 | 18 | import androidx.compose.foundation.text.selection.SelectionContainer |
|
| 18 | 19 | import androidx.compose.material.icons.Icons |
|
| 20 | + | import androidx.compose.material.icons.automirrored.outlined.OpenInNew |
|
| 21 | + | import androidx.compose.material.icons.outlined.ExpandLess |
|
| 22 | + | import androidx.compose.material.icons.outlined.ExpandMore |
|
| 19 | 23 | import androidx.compose.material.icons.outlined.History |
|
| 20 | 24 | import androidx.compose.material3.FilterChip |
|
| 21 | 25 | import androidx.compose.material3.HorizontalDivider |
|
| … | 10 unchanged lines |
||
| 32 | 36 | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 33 | 37 | import androidx.compose.runtime.setValue |
|
| 34 | 38 | import androidx.compose.ui.Modifier |
|
| 39 | + | import androidx.compose.ui.graphics.Color |
|
| 35 | 40 | import androidx.compose.ui.text.font.FontFamily |
|
| 36 | 41 | import androidx.compose.ui.text.style.TextAlign |
|
| 37 | 42 | import androidx.compose.ui.unit.dp |
|
| 38 | 43 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 39 | 44 | import com.mikepenz.markdown.m3.Markdown |
|
| 40 | 45 | import io.oxygenhub.android.api.ChangeSummary |
|
| 46 | + | import io.oxygenhub.android.api.DiffLine |
|
| 41 | 47 | import io.oxygenhub.android.api.FileChange |
|
| 42 | 48 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 43 | 49 | import io.oxygenhub.android.ui.common.LoadContent |
|
| … | 88 unchanged lines |
||
| 132 | 138 | HorizontalDivider() |
|
| 133 | 139 | } |
|
| 134 | 140 | ||
| 135 | - | /** One change: its message, where it is, and the files it touched, each opening at the change. */ |
|
| 141 | + | /** Files a change touched whose diffs open by default: all of them for a small change. */ |
|
| 142 | + | fun openByDefault(files: List<FileChange>): Set<String> = |
|
| 143 | + | if (files.size <= 5 && files.sumOf { it.lines.size } <= 400) files.map { it.path }.toSet() else emptySet() |
|
| 144 | + | ||
| 145 | + | /** One change: its message, where it is, and the files it touched, each with its diff and a way to the file at the change. */ |
|
| 136 | 146 | @Composable |
|
| 137 | 147 | fun ChangeScreen(vm: ChangeViewModel, onBack: () -> Unit, onOpenFile: (path: String) -> Unit) { |
|
| 138 | 148 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 139 | 149 | val status = LocalStatusColors.current |
|
| 140 | 150 | AppScreen(title = vm.hash.take(12), subtitle = "${vm.owner}/${vm.name}", onBack = onBack) { |
|
| 141 | 151 | LoadContent(state, onRefresh = vm::refresh) { d -> |
|
| 152 | + | var open by rememberSaveable(d.change.hash) { mutableStateOf(openByDefault(d.files).toList()) } |
|
| 142 | 153 | LazyColumn { |
|
| 143 | 154 | item { |
|
| 144 | 155 | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { |
|
| … | 10 unchanged lines |
||
| 155 | 166 | } |
|
| 156 | 167 | HorizontalDivider() |
|
| 157 | 168 | } |
|
| 158 | - | items(d.files, key = { it.path }) { f -> FileRow(f, status.ok, status.bad) { if (f.kind != "removed") onOpenFile(f.path) } } |
|
| 169 | + | d.files.forEach { f -> |
|
| 170 | + | val expanded = f.path in open |
|
| 171 | + | item(key = f.path) { |
|
| 172 | + | FileRow( |
|
| 173 | + | f, |
|
| 174 | + | expanded, |
|
| 175 | + | status.ok, |
|
| 176 | + | status.bad, |
|
| 177 | + | onToggle = { open = if (expanded) open - f.path else open + f.path }, |
|
| 178 | + | onOpen = if (f.kind != "removed") ({ onOpenFile(f.path) }) else null, |
|
| 179 | + | ) |
|
| 180 | + | } |
|
| 181 | + | if (expanded) { |
|
| 182 | + | val numbers = f.lines.maxOfOrNull { maxOf(it.old ?: 0, it.new ?: 0) }?.toString()?.length ?: 1 |
|
| 183 | + | itemsIndexed(f.lines, key = { i, _ -> "${f.path}\u0000$i" }) { _, l -> DiffRow(l, numbers, status.ok, status.bad) } |
|
| 184 | + | if (f.lines.isEmpty() || f.truncated) { |
|
| 185 | + | item(key = "${f.path}\u0000end") { |
|
| 186 | + | Text( |
|
| 187 | + | if (f.truncated) "The diff goes on; open the file to see it whole." else "No lines changed (${f.kind}).", |
|
| 188 | + | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), |
|
| 189 | + | style = MaterialTheme.typography.bodySmall, |
|
| 190 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 191 | + | ) |
|
| 192 | + | } |
|
| 193 | + | } |
|
| 194 | + | item(key = "${f.path}\u0000rule") { HorizontalDivider() } |
|
| 195 | + | } |
|
| 196 | + | } |
|
| 159 | 197 | } |
|
| 160 | 198 | } |
|
| 161 | 199 | } |
|
| 162 | 200 | } |
|
| 163 | 201 | ||
| 164 | 202 | @Composable |
|
| 165 | - | private fun FileRow(f: FileChange, added: androidx.compose.ui.graphics.Color, removed: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { |
|
| 203 | + | private fun FileRow( |
|
| 204 | + | f: FileChange, |
|
| 205 | + | expanded: Boolean, |
|
| 206 | + | added: Color, |
|
| 207 | + | removed: Color, |
|
| 208 | + | onToggle: () -> Unit, |
|
| 209 | + | onOpen: (() -> Unit)?, |
|
| 210 | + | ) { |
|
| 166 | 211 | ListItem( |
|
| 167 | - | modifier = Modifier.clickable(onClick = onClick), |
|
| 212 | + | modifier = Modifier.clickable(onClick = onToggle), |
|
| 213 | + | leadingContent = { |
|
| 214 | + | Icon( |
|
| 215 | + | if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, |
|
| 216 | + | contentDescription = if (expanded) "Hide the diff" else "Show the diff", |
|
| 217 | + | ) |
|
| 218 | + | }, |
|
| 168 | 219 | headlineContent = { Text(f.path) }, |
|
| 169 | 220 | supportingContent = { |
|
| 170 | - | Text( |
|
| 171 | - | when (f.kind) { |
|
| 172 | - | "renamed" -> "renamed from ${f.from}" |
|
| 173 | - | else -> f.kind |
|
| 174 | - | }, |
|
| 175 | - | ) |
|
| 176 | - | }, |
|
| 177 | - | trailingContent = { |
|
| 178 | 221 | Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { |
|
| 179 | - | Text("+${f.added}", color = added, style = MaterialTheme.typography.labelMedium) |
|
| 180 | - | Text("−${f.removed}", color = removed, style = MaterialTheme.typography.labelMedium) |
|
| 222 | + | Text(if (f.kind == "renamed") "renamed from ${f.from}" else f.kind) |
|
| 223 | + | Text("+${f.added}", color = added) |
|
| 224 | + | Text("−${f.removed}", color = removed) |
|
| 181 | 225 | } |
|
| 226 | + | }, |
|
| 227 | + | trailingContent = onOpen?.let { open -> |
|
| 228 | + | { IconButton(onClick = open) { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = "The file at this change") } } |
|
| 182 | 229 | }, |
|
| 183 | 230 | ) |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /** A diff line: old and new numbers, then the text on a tint of its kind; long lines wrap. */ |
|
| 234 | + | @Composable |
|
| 235 | + | private fun DiffRow(l: DiffLine, numbers: Int, added: Color, removed: Color) { |
|
| 236 | + | val tint = when (l.kind) { |
|
| 237 | + | "add" -> added.copy(alpha = 0.14f) |
|
| 238 | + | "del" -> removed.copy(alpha = 0.14f) |
|
| 239 | + | "gap" -> MaterialTheme.colorScheme.surfaceVariant |
|
| 240 | + | else -> Color.Transparent |
|
| 241 | + | } |
|
| 242 | + | val gutter = Modifier.width((numbers * 8 + 8).dp).padding(end = 4.dp) |
|
| 243 | + | val small = MaterialTheme.typography.bodySmall |
|
| 244 | + | Row(Modifier.fillMaxWidth().background(tint).padding(horizontal = 8.dp)) { |
|
| 245 | + | if (l.kind == "gap") { |
|
| 246 | + | Text("⋯", modifier = Modifier.padding(vertical = 2.dp), style = small, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 247 | + | return@Row |
|
| 248 | + | } |
|
| 249 | + | Text(l.old?.toString() ?: "", gutter, textAlign = TextAlign.End, fontFamily = FontFamily.Monospace, style = small, color = MaterialTheme.colorScheme.outline) |
|
| 250 | + | Text(l.new?.toString() ?: "", gutter, textAlign = TextAlign.End, fontFamily = FontFamily.Monospace, style = small, color = MaterialTheme.colorScheme.outline) |
|
| 251 | + | Text( |
|
| 252 | + | when (l.kind) { |
|
| 253 | + | "add" -> "+" |
|
| 254 | + | "del" -> "−" |
|
| 255 | + | else -> " " |
|
| 256 | + | }, |
|
| 257 | + | Modifier.width(12.dp), |
|
| 258 | + | fontFamily = FontFamily.Monospace, |
|
| 259 | + | style = small, |
|
| 260 | + | color = if (l.kind == "add") added else if (l.kind == "del") removed else MaterialTheme.colorScheme.outline, |
|
| 261 | + | ) |
|
| 262 | + | Text(l.text, Modifier.weight(1f), fontFamily = FontFamily.Monospace, style = small) |
|
| 263 | + | } |
|
| 184 | 264 | } |
modified app/src/test/java/io/oxygenhub/android/ui/CodeBrowserTest.kt +20 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | import io.oxygenhub.android.FakeHubApi |
|
| 4 | 4 | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.ChangeDetail |
|
| 5 | 6 | import io.oxygenhub.android.api.ChangeSummary |
|
| 7 | + | import io.oxygenhub.android.api.FileChange |
|
| 8 | + | import io.oxygenhub.android.api.HubJson |
|
| 6 | 9 | import io.oxygenhub.android.api.History |
|
| 7 | 10 | import io.oxygenhub.android.api.TreeEntry |
|
| 8 | 11 | import io.oxygenhub.android.ui.code.CodeViewModel |
|
| 9 | 12 | import io.oxygenhub.android.ui.code.HistoryViewModel |
|
| 10 | 13 | import io.oxygenhub.android.ui.code.languageOf |
|
| 14 | + | import io.oxygenhub.android.ui.code.openByDefault |
|
| 11 | 15 | import io.oxygenhub.android.ui.common.Load |
|
| 12 | 16 | import dev.snipme.highlights.model.SyntaxLanguage |
|
| 13 | 17 | import org.junit.Assert.assertEquals |
|
| … | 77 unchanged lines |
||
| 91 | 95 | assertEquals(SyntaxLanguage.SHELL, languageOf("Makefile")) |
|
| 92 | 96 | assertEquals(SyntaxLanguage.DEFAULT, languageOf("pipeline.kdl")) |
|
| 93 | 97 | } |
|
| 98 | + | ||
| 99 | + | @Test |
|
| 100 | + | fun aChangeCarriesItsDiffAndSmallOnesOpen() { |
|
| 101 | + | val json = """{"change":{"hash":"ab","message":"grow a"},"files":[{"path":"a.txt","kind":"modified","added":1,"removed":1, |
|
| 102 | + | "lines":[{"kind":"context","old":1,"new":1,"text":"a"},{"kind":"del","old":2,"text":"b"},{"kind":"add","new":2,"text":"c"},{"kind":"gap"}]}]}""" |
|
| 103 | + | val d = HubJson.decodeFromString(ChangeDetail.serializer(), json) |
|
| 104 | + | val f = d.files.single() |
|
| 105 | + | assertEquals(listOf("context", "del", "add", "gap"), f.lines.map { it.kind }) |
|
| 106 | + | assertEquals(2 to null, f.lines[1].old to f.lines[1].new) |
|
| 107 | + | assertFalse(f.truncated) |
|
| 108 | + | assertEquals(setOf("a.txt"), openByDefault(d.files)) |
|
| 109 | + | val many = (1..6).map { FileChange("f$it") } |
|
| 110 | + | assertTrue("a change of many files starts folded", openByDefault(many).isEmpty()) |
|
| 111 | + | val old = HubJson.decodeFromString(ChangeDetail.serializer(), """{"change":{"hash":"ab"},"files":[{"path":"a.txt"}]}""") |
|
| 112 | + | assertTrue("a hub before #320 sends no lines", old.files.single().lines.isEmpty()) |
|
| 113 | + | } |
|
| 94 | 114 | } |