OXYGENHUB

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
}